Compare commits

...

33 Commits

Author SHA1 Message Date
zhangheng.023
d48f84c096 opt(round-003): 复测 效果+0.200 token+17 耗时-1612ms → 采纳 2026-06-23 20:09:03 +08:00
zhangheng.023
cbd6e56ac0 opt(round-003): references/lark-im-chat-create.md — dedup Commands/Scenarios overlap + compress --help-mirroring Common Errors into pointers, keep 232043 two-step flow & all guardrails 2026-06-23 19:51:49 +08:00
zhangheng.023
572eb8da41 opt(round-002): 复测 效果+0.400 token-2464 耗时+771ms → 采纳 2026-06-23 19:37:11 +08:00
zhangheng.023
82a099feaf opt(round-002): lark-im-messages-send.md — consolidate 4x repeated content-flag rule, compress media enumeration & --help-mirror sections 2026-06-23 19:17:24 +08:00
zhangheng.023
51f2a70e6d opt(round-001): 复测 效果+0.156 token+7779 耗时+17836ms → 采纳 2026-06-23 18:51:18 +08:00
zhangheng.023
237a77feb3 opt(round-001): SKILL.md — fold USAGE method-index + scope table into a schema pointer (-40% resident tokens) 2026-06-23 18:27:25 +08:00
zhangheng.023
040ef17eae opt(baseline): 题库 + Φ0 基线产物(优化起点) 2026-06-23 18:12:57 +08:00
liangshuo-1
736b131cdf fix(meta): backfill enum value descriptions from options (#1541) 2026-06-23 16:14:42 +08:00
arnold9672
5efaf65aec feat: surface search API notices (#1413)
* feat: surface search API notices

sa: safe
doc: none
cfg: none
test: unit test

* fix: surface search notices in default output

* docs: add search notice doc comments

* docs: expand search notice doc comments
2026-06-23 14:27:04 +08:00
linchao5102
0991da7446 fix: add missing CLI headers for git credential helper (#1539) 2026-06-23 14:25:26 +08:00
zgz2048
80bea45c6a feat: support base record comments (#1043)
* feat: support base record comments

* fix: tighten base comment validation

* fix: validate wiki base comment flags
2026-06-23 11:20:07 +08:00
bubbmon233
c775cb4360 docs(mail): trim lark-mail skill context (#1527) 2026-06-22 21:32:31 +08:00
jiangguozhou
824aa9edf8 docs: add lark-drive permission governance workflow (#1292)
Change-Id: Ib62bd439669fec3e9d5589d1fbe266d3aef964a8
2026-06-22 14:20:02 +08:00
zhanghuanxu
9d4ae94394 feat(slides):slide screenshot 2026-06-22 13:20:39 +08:00
liangshuo-1
bba13cfe0f chore: release v1.0.56 (#1518) 2026-06-18 18:53:21 +08:00
liujiashu-shiro
815cdb8f1c feat(im/convert): support content_v2 blocks in post message conversion (#1411)
Support content_v2 post message conversion in IM shortcuts so newer post payloads render with the expected markdown, mention, and image formats while preserving fallback compatibility with legacy content.
2026-06-18 17:53:22 +08:00
liangshuo-1
4f3ae0c71a fix: pin fetch_meta.py output to utf-8 encoding (#1516) 2026-06-18 17:18:45 +08:00
91-enjoy
96d70143c5 feat: support message recieve event card format (#1480)
Previously, im.message.receive events with message_type: interactive surfaced the raw JSON
payload as content, requiring callers to manually parse the card schema. This PR introduces a
user_dsl renderer (ConvertInteractiveEventContent) that converts interactive card content into
structured human-readable text — consistent with how text, post, image, and other message
types are already handled.

The output format is <card title="..." subtitle="...">...</card>, with each card element type
serialised to a readable representation (markdown body, button links, table rows, chart summaries,
etc.).
2026-06-18 17:18:01 +08:00
syh-cpdsss
83db15907f Improve OKR shortcuts (#1487)
* feat(okr): add +batch-create, +reorder, +weight shortcuts

Add three new OKR shortcuts for managing objectives and key results:

- +batch-create: Bulk create objectives with key results, with automatic
  rollback on failure
- +reorder: Adjust position of objectives or key results within a cycle/objective
- +weight: Adjust weights of objectives or key results with automatic
  normalization using fixed-point arithmetic to avoid float precision issues

Key implementation details:
- API paths use underscore separators (/objectives_position, /objectives_weight)
- Weight normalization uses json.Number for precise JSON serialization
- Items are sorted by position before API calls to match backend requirements
- Full unit test coverage and dry-run/live E2E tests
- Skill documentation with usage examples and parameter descriptions

Change-Id: I92b658e0cc42ffa8cbdaec2ec628a079bcfc38f5

* fix: skill simplify & minor fix

Change-Id: I3f27a01cdae2122f26e48ee2acb7f334f2bab7d2

* fix: CR issue

Change-Id: Id9fab84e06f0d67e9f79c1fb9946b6b633200592

* fix: CR issue 2

Change-Id: I6a5e57dd4b10dc79f8681ec614354fbba82abc04

* fix: error handle of +weight shortcut

Change-Id: I6e2a39269e62e3b504e681110843b2ccc315a527
2026-06-18 16:25:23 +08:00
hanshaoshuai
1f2164c7c2 fix: trim semantic review input for broad changes 2026-06-17 20:15:04 +08:00
raistlin042
76f5419a0d feat: add +session-messages-list for session turn reply messages (#1402)
* feat(apps): add +message-get to fetch session turn reply messages

* docs(apps): add +message-get skill reference

* fix(apps): drop Required flags on +message-get so missing ids return structured exit-2 envelope

* docs(apps): route turn reply-message queries to +message-get in SKILL.md

* docs(apps): guide cloud-dev to read live turn progress via +message-get

* docs(apps): note +message-get reads a still-running turn incrementally

* docs(apps): route live-turn reply queries to +message-get in SKILL.md

* refactor(apps): rename +message-get to +session-messages-list with page_token paging

* refactor(apps): use typed errs validation in +session-messages-list

* docs(apps): clarify +session-messages-list paging stops on has_more, not token
2026-06-17 20:12:22 +08:00
evandance
c5b5aece33 refactor: retire legacy error envelopes and enforce typed contract (#1449)
* refactor: retire legacy error envelopes and enforce typed contract

Consolidate all command error reporting onto the typed errs.* contract, remove
the legacy error surface that predated it, and tighten the lint guards so the
contract holds across the whole repository going forward.

Every failure now reaches stderr as one envelope shape: a category, an
optional subtype, a human- and agent-readable message, and a recovery hint,
with invalid parameters listed under `params`. The legacy ExitError envelope,
its constructors, and the boundary bridge that promoted untyped config and
authorization errors are deleted, leaving a single path from error to wire.
Predicate commands keep their silent-exit behavior through a dedicated signal
that carries only an exit code.

Infrastructure paths that still emitted ad-hoc envelopes — flag parsing,
unknown commands and subcommands, plugin and policy guards, confirmation
prompts, and auth/config failures — now classify into the same taxonomy.
Business, API, auth, and config exit codes are preserved; the one behavioral
change is that Cobra usage failures (missing required flag, unknown command,
bad arguments) now emit the typed validation envelope and exit 2, matching the
explicit flag and subcommand guards, instead of Cobra's plain-text exit 1.

Enforcement is repo-wide rather than per-path:
- The errscontract guards run by default everywhere instead of through a
  migration allowlist, so legacy envelopes cannot be reintroduced anywhere.
- errorlint runs across the whole repository: every error wrap must use %w and
  every comparison must use errors.Is/errors.As, so interior wraps stay legal
  but can no longer break the chain the typed boundary relies on.
- The errs-no-bare-wrap guard is keyed by structural prefix instead of an
  explicit per-domain allowlist, so new shortcut domains are covered without
  editing a list. It runs where forbidigo is enabled (the shortcut domains and
  the auth/config/service command groups); repo-wide chain integrity for the
  remaining command paths is carried by errorlint above.

* test: align cli_e2e success assertions to the ok envelope

The api and service success path now emits the {"ok":true} envelope, so the
cli_e2e workflow assertions that still expected the old {"code":0} shape via
AssertStdoutStatus(t, 0) fail once they run with live credentials. Switch those
workflow assertions to AssertStdoutStatus(t, true); the fake-payload helper test
in core_test.go keeps its code-shape assertion.
2026-06-17 19:42:38 +08:00
fangshuyu-768
d687a76c79 feat: soften lark doc style guidance (#1463) 2026-06-17 19:16:02 +08:00
guokexin.02
4a4c3344c8 fix: align api success envelopes (#1489) 2026-06-17 17:41:48 +08:00
hanshaoshuai
c61acb5264 feat: add ci quality gate 2026-06-17 16:29:33 +08:00
zgz2048
7eeb111a2d fix: reject out-of-range base pagination flags (#1495) 2026-06-17 15:41:59 +08:00
liangshuo-1
714da970d0 chore(release): v1.0.55 (#1490) 2026-06-16 22:26:40 +08:00
sang-neo03
ed7fdd1a27 feat: optimize event subscription precheck, links, and consumer guard (#1447)
* feat: add SubscriptionType and SingleConsumer to EventKey definition

* feat: fetch subscribed callbacks from application/get

* feat: build addons scan-to-enable deep link for event precheck

* feat: route callback precheck to application/get and emit scan links

* feat: add reject fields to hello_ack protocol message

* feat: add exclusive registration to event bus hub

* feat: reject duplicate consumer for SingleConsumer EventKey at bus handshake

* feat: surface bus consumer rejection as failed_precondition error

* fix: encode empty addons sides as [] not null per launcher contract

* fix: report missing callbacks when console has none subscribed

* feat: bound exclusive consumer cleanup wait with configurable timeout

* refactor: drain exclusive-wait timer and document websocket-only callbacks

* fix: use camelCase clientID param in event scan-to-enable link

* test: cover null/omitted callbacks and assert typed error category

* fix: keep auth login remediation for user-identity missing scopes

* refactor: simplify SubscriptionType normalization to match validateAuth style
2026-06-16 19:41:52 +08:00
wangweiming-01
4464ba7660 fix: validate drive import folder target (#1485)
Change-Id: I43755c3966b0daa06b708d2b3d03294f439547fa
2026-06-16 18:14:08 +08:00
zhicong666-bytedance
bb03c8ac4d feat(vc): support agent meeting event workflows (#1483)
* feat: support vc agent active meetings

* docs: clarify vc agent active meeting flow

* fix: align active meeting shortcut scope

* docs: clarify active meeting id fields

* fix: reject meeting numbers for vc events

* docs: clarify vc agent active meeting flow

* docs: refine vc agent meeting flow guidance

* docs: address vc agent skill review feedback

* docs: clarify vc meeting product wording

* docs: align vc agent skill with quality guidelines

* docs: trim vc agent skill token budget

* Revert "docs: trim vc agent skill token budget"

This reverts commit 8560bb9c19.
2026-06-16 18:08:07 +08:00
yballul-bytedance
3feb70b32a feat(drive): 支持导出 Base 结构快照 (#1481)
1. 为 drive +export 增加 --only-schema 参数,并透传 only_schema 到导出任务请求。
2. 限制该参数仅用于 bitable 导出 .base,并补充单测与 dry-run E2E 覆盖。

Change-Id: I736cebf5841cc1c6acaa8a3ab16be51ba4cb355d
2026-06-16 16:36:31 +08:00
ZEden0
64b1b3f3ed feat(docs): support lang for fetch v2 (#1459) 2026-06-16 16:25:36 +08:00
ZEden0
a0e83c7e59 feat(docs): add docx cover resource commands (#1468)
Spec source: active@bd186a6373948acc76d8b0872334b1a53ad40f5645b1a4e129937d7a51f5596c
2026-06-16 15:25:37 +08:00
489 changed files with 52065 additions and 5409 deletions

View File

@@ -10,8 +10,6 @@ on:
permissions:
contents: read
actions: read
checks: write
pull-requests: write
jobs:
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
@@ -80,10 +78,47 @@ jobs:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Resolve changed-from baseline
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: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
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 errs/ lint guards (lintcheck)
run: go run -C lint . ..
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
deterministic-gate:
needs: fast-gate
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Resolve changed-from baseline
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: Run CLI deterministic gate
run: make quality-gate
- name: Upload quality gate facts
if: ${{ always() && github.event_name == 'pull_request' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: quality-gate-facts-${{ github.event.pull_request.base.sha }}-${{ github.event.pull_request.head.sha }}
path: .tmp/quality-gate/facts.json
if-no-files-found: error
retention-days: 7
coverage:
needs: fast-gate
@@ -103,6 +138,7 @@ jobs:
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Upload coverage to Codecov
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
with:
files: coverage.txt
@@ -184,7 +220,7 @@ jobs:
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
e2e-dry-run:
needs: [unit-test, lint]
needs: [unit-test, lint, deterministic-gate]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
@@ -205,9 +241,12 @@ jobs:
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
needs: [unit-test, lint]
needs: [unit-test, lint, deterministic-gate]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
permissions:
contents: read
checks: write
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
@@ -254,6 +293,9 @@ jobs:
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
security:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -291,7 +333,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
needs: [fast-gate, unit-test, lint, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -303,6 +345,7 @@ jobs:
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
@@ -318,6 +361,7 @@ jobs:
"${{ needs.fast-gate.result }}" \
"${{ needs.unit-test.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.deterministic-gate.result }}" \
"${{ needs.coverage.result }}" \
"${{ needs.deadcode.result }}" \
"${{ needs.e2e-dry-run.result }}" \

560
.github/workflows/semantic-review.yml vendored Normal file
View File

@@ -0,0 +1,560 @@
name: Semantic Review
on:
workflow_run:
workflows: ["CI"]
types: [completed]
permissions:
actions: read
contents: read
jobs:
pr-quality-summary:
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
issues: write
pull-requests: write
steps:
- name: Verify workflow run and pull request for summary
id: pr
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
if (runPRs.length > 1) {
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
let eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = eventHeadSha || run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
let factsArtifactName = "";
let artifactBaseSha = "";
let artifactError = "";
if (factsArtifacts.length !== 1) {
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
} else {
factsArtifactName = factsArtifacts[0].name;
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
}
}
if (!prNumber) {
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.state === "open" &&
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
if (candidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
}
if (candidatePRs.length === 1) {
prNumber = candidatePRs[0].number;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
if (candidatePRs.length !== 1) {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
prNumber = candidatePRs[0].number;
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.head.sha !== targetHeadSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
return;
}
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
core.setOutput("stale", "true");
return;
}
if (artifactError) {
core.warning(`quality gate facts artifact binding is unavailable: ${artifactError}`);
}
core.setOutput("pr_number", String(prNumber));
core.setOutput("head_sha", targetHeadSha);
core.setOutput("base_sha", baseSha);
core.setOutput("run_id", String(run.id));
core.setOutput("facts_artifact_name", factsArtifactName);
core.setOutput("artifact_error", artifactError);
core.setOutput("stale", "false");
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
id: checkout
if: ${{ steps.pr.outputs.stale != 'true' }}
with:
ref: ${{ steps.pr.outputs.base_sha }}
persist-credentials: false
- name: Verify summary facts artifact metadata
id: artifact
if: ${{ steps.pr.outputs.stale != 'true' && steps.pr.outputs.facts_artifact_name != '' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
const artifact = artifacts[0];
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
}
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
core.setOutput("artifact_id", String(artifact.id));
core.setOutput("artifact_digest", artifact.digest);
- name: Download facts artifact zip
if: ${{ steps.pr.outputs.stale != 'true' && steps.artifact.outputs.artifact_id != '' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
id: download
with:
script: |
const fs = require("fs");
const path = require("path");
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
const { data } = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifactId,
archive_format: "zip",
});
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
fs.writeFileSync(zipPath, Buffer.from(data));
core.setOutput("zip_path", zipPath);
- name: Verify and extract summary facts artifact
if: ${{ steps.pr.outputs.stale != 'true' && steps.download.outputs.zip_path != '' }}
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_DECISION_OUT: decision.json
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
- name: Publish PR quality summary
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
CI_QUALITY_SUMMARY_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
CI_QUALITY_SUMMARY_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
CI_QUALITY_SUMMARY_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
CI_QUALITY_SUMMARY_RUN_ID: ${{ steps.pr.outputs.run_id }}
CI_QUALITY_SUMMARY_ARTIFACT_ERROR: ${{ steps.pr.outputs.artifact_error }}
with:
script: |
const { publish } = require("./scripts/ci-quality-summary-publish.js");
await publish({ github, context, core });
semantic-review:
needs: pr-quality-summary
if: always() && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: read
checks: write
contents: read
issues: write
pull-requests: write
steps:
- name: Verify workflow run and pull request
id: pr
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
if (runPRs.length > 1) {
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
let eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = eventHeadSha || run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
let factsArtifactName = "";
let artifactBaseSha = "";
let artifactError = "";
if (factsArtifacts.length !== 1) {
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
} else {
factsArtifactName = factsArtifacts[0].name;
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
}
}
if (!prNumber) {
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.state === "open" &&
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
if (candidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
}
if (candidatePRs.length === 1) {
prNumber = candidatePRs[0].number;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
if (candidatePRs.length !== 1) {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
prNumber = candidatePRs[0].number;
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.head.sha !== targetHeadSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
return;
}
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR base");
core.setOutput("stale", "true");
return;
}
if (artifactError) {
core.warning(`semantic review facts artifact binding is unavailable: ${artifactError}`);
}
core.setOutput("pr_number", String(prNumber));
core.setOutput("head_sha", targetHeadSha);
core.setOutput("base_sha", baseSha);
core.setOutput("head_owner", pr.head.repo.owner.login);
core.setOutput("head_repo", pr.head.repo.name);
core.setOutput("head_repo_id", String(pr.head.repo.id));
core.setOutput("head_is_base_repo", pr.head.repo.id === context.payload.repository.id ? "true" : "false");
core.setOutput("run_id", String(run.id));
core.setOutput("facts_artifact_name", factsArtifactName);
core.setOutput("artifact_error", artifactError);
core.setOutput("stale", "false");
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
id: checkout
if: ${{ steps.pr.outputs.stale != 'true' }}
with:
ref: ${{ steps.pr.outputs.base_sha }}
persist-credentials: false
- name: Publish pre-checkout semantic review failure
if: ${{ failure() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome != 'success' && steps.pr.outputs.head_sha != '' && steps.pr.outputs.pr_number != '' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
with:
script: |
const runtimeBlockMode = process.env.SEMANTIC_REVIEW_BLOCK === "true";
const pr = Number(process.env.SEMANTIC_REVIEW_PR_NUMBER || 0);
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
const baseSha = process.env.SEMANTIC_REVIEW_BASE_SHA || "";
if (!Number.isInteger(pr) || pr <= 0 || !/^[a-f0-9]{40}$/i.test(headSha) || !/^[a-f0-9]{40}$/i.test(baseSha)) {
throw new Error("missing verified semantic review target");
}
const { data: pull } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr,
});
if (pull.head.sha !== headSha) {
core.notice("semantic review skipped infrastructure failure check: PR head changed");
return;
}
if (pull.base.sha !== baseSha) {
core.notice("semantic review skipped infrastructure failure check: PR base changed");
return;
}
if (pull.base.repo.id !== context.payload.repository.id) {
throw new Error("PR base repo mismatch before infrastructure failure check");
}
await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: runtimeBlockMode ? "semantic-review/result" : "semantic-review/observe",
head_sha: headSha,
status: "completed",
conclusion: runtimeBlockMode ? "failure" : "neutral",
output: {
title: "Semantic review infrastructure failure",
summary: "Semantic review could not checkout the verified base commit. Inspect the workflow logs before relying on semantic review output.",
},
});
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
if: ${{ steps.pr.outputs.stale != 'true' }}
with:
go-version-file: go.mod
- name: Verify semantic facts artifact metadata
id: artifact
if: ${{ steps.pr.outputs.stale != 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
if (!/^quality-gate-facts-[a-f0-9]{40}-[a-f0-9]{40}$/i.test(factsArtifactName)) {
throw new Error("missing verified facts artifact binding");
}
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
const artifact = artifacts[0];
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
}
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
core.setOutput("artifact_id", String(artifact.id));
core.setOutput("artifact_digest", artifact.digest);
- name: Download facts artifact zip
if: ${{ steps.pr.outputs.stale != 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
id: download
with:
script: |
const fs = require("fs");
const path = require("path");
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
const { data } = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifactId,
archive_format: "zip",
});
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
fs.writeFileSync(zipPath, Buffer.from(data));
core.setOutput("zip_path", zipPath);
- name: Verify and extract semantic facts artifact
if: ${{ steps.pr.outputs.stale != 'true' }}
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_DECISION_OUT: decision.json
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
- name: Download PR semantic waiver config
id: waiver_config
if: ${{ steps.pr.outputs.stale != 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
SEMANTIC_REVIEW_HEAD_OWNER: ${{ steps.pr.outputs.head_owner }}
SEMANTIC_REVIEW_HEAD_REPO: ${{ steps.pr.outputs.head_repo }}
SEMANTIC_REVIEW_HEAD_IS_BASE_REPO: ${{ steps.pr.outputs.head_is_base_repo }}
with:
script: |
const fs = require("fs");
const path = require("path");
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
if (!/^[a-f0-9]{40}$/i.test(headSha)) {
throw new Error("missing verified semantic review target");
}
const headOwner = process.env.SEMANTIC_REVIEW_HEAD_OWNER || "";
const headRepo = process.env.SEMANTIC_REVIEW_HEAD_REPO || "";
if (!headOwner || !headRepo) {
throw new Error("missing verified semantic review head repository");
}
const waiverPath = "internal/qualitygate/config/semantic/waivers.txt";
const outPath = path.join(process.env.RUNNER_TEMP, "semantic-review-waivers.txt");
const headIsBaseRepo = process.env.SEMANTIC_REVIEW_HEAD_IS_BASE_REPO === "true";
if (!headIsBaseRepo) {
core.notice("fork PR semantic waiver config is ignored");
core.setOutput("path", "");
return;
}
let content = "";
try {
const { data } = await github.rest.repos.getContent({
owner: headOwner,
repo: headRepo,
path: waiverPath,
ref: headSha,
});
if (Array.isArray(data) || data.type !== "file" || data.encoding !== "base64") {
throw new Error(`${waiverPath} is not a base64 file at PR head`);
}
if (data.size > 256 * 1024) {
throw new Error(`${waiverPath} is too large: ${data.size} bytes`);
}
content = Buffer.from(data.content, "base64").toString("utf8");
} catch (err) {
if (err.status !== 404) {
throw err;
}
}
fs.writeFileSync(outPath, content);
core.setOutput("path", outPath);
- name: Run semantic review
id: semantic
if: ${{ steps.pr.outputs.stale != 'true' }}
env:
ARK_API_KEY: ${{ secrets.ARK_API_KEY }}
ARK_BASE_URL: ${{ vars.ARK_BASE_URL }}
ARK_MODEL: ${{ vars.ARK_MODEL }}
ARK_TIMEOUT_SECONDS: ${{ vars.ARK_TIMEOUT_SECONDS }}
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
run: |
args=(
--repo .
--facts facts.json
--decision-out decision.json
--markdown-out semantic-review.md
)
if [ -n "${{ steps.waiver_config.outputs.path }}" ]; then
args+=(--waivers-file '${{ steps.waiver_config.outputs.path }}')
fi
if [ "$SEMANTIC_REVIEW_BLOCK" = "true" ]; then
args+=(--block)
fi
go run ./internal/qualitygate/cmd/semantic-review "${args[@]}"
- name: Publish semantic review
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
with:
script: |
const { publish } = require("./scripts/semantic-review-publish.js");
await publish({ github, context, core });

View File

@@ -29,11 +29,11 @@ linters:
- unused # checks for unused constants, variables, functions and types
- depguard # blocks forbidden package imports
- forbidigo # forbids specific function calls
- errorlint # enforces error wrapping (%w) and errors.Is/As over == and type asserts
# To enable later after fixing existing issues:
# - errcheck # checks for unchecked errors
# - errname # checks that error types are named XxxError
# - errorlint # checks error wrapping best practices
# - gosec # security-oriented linter
# - misspell # finds commonly misspelled English words
# - staticcheck # comprehensive static analysis
@@ -49,9 +49,16 @@ linters:
- gocritic
- depguard
- forbidigo
# Paths that run forbidigo. Add an entry when a path joins one of
# the rules below.
- errorlint # tests legitimately do identity (==) and concrete type-assert checks
# forbidigo runs repo-wide (minus the boundaries below) so errs-no-bare-wrap
# has no gap. The framework bans (os/vfs, raw HTTP, fmt.Print, filepath,
# log) stay scoped to shortcuts/ + internal/ + config/auth/service via the
# next rule; elsewhere only errs-no-bare-wrap fires.
- path-except: (shortcuts/|internal/|cmd/|events/)
linters:
- forbidigo
- path-except: (shortcuts/|internal/|cmd/auth/|cmd/config/|cmd/service/)
text: (vfs|IOStreams|ctx\.Out|shortcuts-no-raw-http|filepath functions|os\.Exit|structured error return)
linters:
- forbidigo
- path: internal/vfs/
@@ -65,31 +72,26 @@ linters:
- path: shortcuts/.*/internal/gen/
linters:
- forbidigo
# internal/qualitygate/cmd contains standalone CI tools. Their main
# entrypoints legitimately own process exit codes and stdio, matching the
# old tools/ layout before these packages moved under internal/.
- path: internal/qualitygate/cmd/[^/]+/main\.go$
linters:
- forbidigo
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
# for the client / credential layer.
- path-except: shortcuts/
text: shortcuts-no-raw-http
linters:
- forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
# errs-no-bare-wrap enforced across every command/wire boundary by
# structural prefix, so any future business domain or command is covered
# without editing an allowlist. Genuine intermediate wraps inside these
# paths use //nolint:forbidigo with a reason.
- path-except: (cmd/|shortcuts/|events/)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper enforced on domains whose shared validation/save
# helpers have migrated to typed final errors.
- path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
text: errs-no-legacy-helper
linters:
- forbidigo
settings:
depguard:
@@ -108,22 +110,6 @@ linters:
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
forbidigo:
forbid:
# ── legacy output.Err* helpers banned on migrated paths ──
# output.ErrBare is intentionally not listed — it is the predicate-
# command silent-exit signal, outside the typed envelope contract.
- pattern: output\.(ErrValidation|ErrAuth|ErrNetwork|ErrAPI|ErrWithHint|Errorf)\b
msg: >-
[errs-typed-only] use errs.NewXxxError(...) builder
(see errs/types.go).
# ── legacy shared error helpers banned on migrated domains ──
# These helpers emit legacy output.Err* / bare error shapes or drop
# typed metadata such as Param/Cause. Migrated domains must use typed
# common replacements or local typed helpers instead.
- pattern: (common\.FlagErrorf|common\.RejectDangerousChars|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
msg: >-
[errs-no-legacy-helper] these shared helpers emit legacy or
metadata-poor error shapes. Use typed common replacements, typed
errs.NewXxxError builders, or domain-local typed helpers.
# ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b
msg: >-

View File

@@ -2,6 +2,43 @@
All notable changes to this project will be documented in this file.
## [v1.0.56] - 2026-06-18
### Features
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
### Bug Fixes
- **api**: Align API success envelopes (#1489)
- **base**: Reject out-of-range pagination flags (#1495)
### Refactor
- Retire legacy error envelopes and enforce typed contract (#1449)
### Documentation
- **skills**: Soften lark-doc style guidance (#1463)
### Build
- Add CI quality gate with semantic review
## [v1.0.55] - 2026-06-16
### Features
- **vc**: Support agent meeting event workflows (#1483)
- **drive**: Support exporting Base structure snapshots (#1481)
- **doc**: Add docx cover resource commands (#1468)
- **doc**: Support `lang` for docx fetch v2 (#1459)
- **event**: Optimize subscription precheck, links, and consumer guard (#1447)
### Bug Fixes
- **drive**: Validate drive import folder target (#1485)
## [v1.0.54] - 2026-06-15
### Features
@@ -1175,6 +1212,8 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52

View File

@@ -5,6 +5,13 @@ BINARY := lark-cli
MODULE := github.com/larksuite/cli
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
DATE := $(shell date +%Y-%m-%d)
NODE ?= node
QUALITY_GATE_CHANGED_FROM ?= $(shell bash scripts/resolve-changed-from.sh)
QUALITY_GATE_CHANGED_FROM_RESOLVED = $(if $(strip $(QUALITY_GATE_CHANGED_FROM)),$(QUALITY_GATE_CHANGED_FROM),$(shell bash scripts/resolve-changed-from.sh))
QUALITY_GATE_DIR ?= .tmp/quality-gate
QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json
QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json
QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local
@@ -15,7 +22,7 @@ PREFIX ?= /usr/local
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
all: test
@@ -39,6 +46,12 @@ fmt-check:
exit 1; \
fi
script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test 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
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
@@ -53,7 +66,30 @@ examples-build:
integration-test: build
go test -v -count=1 ./tests/...
test: vet fmt-check unit-test examples-build integration-test
test: vet fmt-check script-test unit-test examples-build integration-test
quality-gate: build
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT))
LARKSUITE_CLI_REMOTE_META=off \
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
go run ./internal/qualitygate/cmd/manifest-export \
--manifest-out $(QUALITY_GATE_MANIFEST_OUT) \
--command-index-out $(QUALITY_GATE_COMMAND_INDEX_OUT)
LARKSUITE_CLI_APP_ID=dry-run \
LARKSUITE_CLI_APP_SECRET=dry-run \
LARKSUITE_CLI_BRAND=feishu \
LARKSUITE_CLI_CONFIG_DIR=$${TMPDIR:-/tmp}/quality-gate-cli-config \
LARKSUITE_CLI_REMOTE_META=off \
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
go run ./internal/qualitygate/cmd/quality-gate check \
--repo . \
--cli-bin ./$(BINARY) \
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
--facts-out $(QUALITY_GATE_FACTS_OUT)
install: build
install -d $(PREFIX)/bin

View File

@@ -10,6 +10,7 @@ import (
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -123,7 +124,13 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
// stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--params and --data cannot both read from stdin (-)").
WithHint("pass at most one flag as '-'; give the other inline JSON or @file").
WithParams(
errs.InvalidParam{Name: "--params", Reason: "reads from stdin (-)"},
errs.InvalidParam{Name: "--data", Reason: "reads from stdin (-)"},
)
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
@@ -153,7 +160,10 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
return client.RawApiRequest{}, nil, err
}
if _, ok := dataFields.(map[string]any); !ok {
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--data must be a JSON object when used with --file").
WithHint(`with --file, --data carries multipart form fields, e.g. --data '{"image_type":"message"}'`).
WithParam("--data")
}
}
@@ -196,7 +206,13 @@ func apiRun(opts *APIOptions) error {
}
if opts.PageAll && opts.Output != "" {
return output.ErrValidation("--output and --page-all are mutually exclusive")
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--output and --page-all are mutually exclusive").
WithHint("drop --page-all to save a binary response, or drop --output to paginate JSON").
WithParams(
errs.InvalidParam{Name: "--output", Reason: "conflicts with --page-all"},
errs.InvalidParam{Name: "--page-all", Reason: "conflicts with --output"},
)
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
@@ -233,7 +249,7 @@ func apiRun(opts *APIOptions) error {
}
if opts.PageAll {
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
}
@@ -243,7 +259,7 @@ func apiRun(opts *APIOptions) error {
// pass on *output.ExitError values. Typed *errs.* errors that flow
// through here keep their canonical message / hint from BuildAPIError;
// MarkRaw is a no-op on those (it only flips a flag on *ExitError).
return output.MarkRaw(err)
return errs.MarkRaw(err)
}
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
@@ -263,7 +279,7 @@ func apiRun(opts *APIOptions) error {
// MarkRaw: see comment above on the DoAPI path. Skips legacy
// *ExitError enrichment; typed errors flow through unchanged.
if err != nil {
return output.MarkRaw(err)
return errs.MarkRaw(err)
}
return nil
}
@@ -272,46 +288,76 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, ac.CheckResponse); err != nil {
return output.MarkRaw(err)
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
}
return nil
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return output.MarkRaw(err)
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return output.MarkRaw(apiErr)
return errs.MarkRaw(apiErr)
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
output.FormatValue(out, result, output.FormatJSON)
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
return nil
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return output.MarkRaw(err)
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return output.MarkRaw(apiErr)
return errs.MarkRaw(apiErr)
}
output.FormatValue(out, result, format)
return nil
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
}

View File

@@ -4,6 +4,8 @@
package api
import (
"context"
"encoding/json"
"errors"
"os"
"sort"
@@ -11,6 +13,7 @@ import (
"testing"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -101,8 +104,19 @@ func TestApiCmd_BotMode(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "success") {
t.Error("expected 'success' in output")
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
if got["ok"] != true || got["identity"] != "bot" {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if !ok || data["result"] != "success" {
t.Fatalf("data = %#v, want result=success", got["data"])
}
}
@@ -328,8 +342,16 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
t.Error("expected 'falling back to json' in stderr")
}
// Should output JSON result to stdout
if !strings.Contains(stdout.String(), "u123") {
t.Error("expected user_id in JSON output")
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok || data["user_id"] != "u123" {
t.Fatalf("unexpected fallback envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("fallback success envelope leaked outer code: %s", stdout.String())
}
}
@@ -342,7 +364,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
Body: map[string]interface{}{
"code": 230001, "msg": "no permission",
"code": 230027, "msg": "user not authorized",
},
})
@@ -354,12 +376,20 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
t.Fatal("expected an error for non-zero code")
}
// Should still output the response body so user can see the error details
if !strings.Contains(stdout.String(), "230001") {
if !strings.Contains(stdout.String(), "230027") {
t.Errorf("expected error response in stdout, got: %s", stdout.String())
}
if !strings.Contains(stdout.String(), "no permission") {
if !strings.Contains(stdout.String(), "user not authorized") {
t.Errorf("expected error message in stdout, got: %s", stdout.String())
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected PermissionError, got %T: %v", err, err)
}
}
func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
@@ -395,6 +425,274 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
}
}
func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code on later page")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier successful page to remain streamed, got: %s", out)
}
if strings.Contains(out, "230027") || strings.Contains(out, "user not authorized") {
t.Fatalf("streaming stdout should not contain raw error JSON, got: %s", out)
}
if strings.Contains(out, "\n \"code\"") {
t.Fatalf("streaming stdout should not contain indented JSON error dump, got: %s", out)
}
}
func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("data.items = %#v, want one item", data["items"])
}
}
type apiContentSafetyProvider struct {
called bool
path string
data interface{}
match string
}
func (p *apiContentSafetyProvider) Name() string { return "api-test" }
func (p *apiContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
p.called = true
p.path = req.Path
p.data = req.Data
if p.match != "" {
b, _ := json.Marshal(req.Data)
if !strings.Contains(string(b), p.match) {
return nil, nil
}
}
return &extcs.Alert{Provider: "api-test", MatchedRules: []string{"pagination"}}, nil
}
func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &apiContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan paginated output")
}
if provider.path != "api" {
t.Fatalf("scan path = %q, want api", provider.path)
}
data, ok := provider.data.(map[string]interface{})
if !ok {
t.Fatalf("scanned data type = %T, want map", provider.data)
}
if _, hasCode := data["code"]; hasCode {
t.Fatalf("scanned data should be business data only, got %#v", data)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
alert, ok := got["_content_safety_alert"].(map[string]interface{})
if !ok || alert["provider"] != "api-test" {
t.Fatalf("missing content safety alert in envelope: %#v", got)
}
}
func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &apiContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan streamed paginated output")
}
if provider.path != "api" {
t.Fatalf("scan path = %q, want api", provider.path)
}
items, ok := provider.data.([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
}
if !strings.Contains(stderr.String(), "warning: content safety alert from api-test") {
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
}
if !strings.Contains(stdout.String(), `"id":"1"`) {
t.Fatalf("expected streamed ndjson output, got: %s", stdout.String())
}
}
func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
provider := &apiContentSafetyProvider{match: "blocked"}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "blocked-page"}},
"has_more": false,
},
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute()
if err == nil {
t.Fatal("expected content safety block error")
}
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
}
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
}
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "pagination" {
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
}
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
}
if strings.Contains(out, "blocked-page") {
t.Fatalf("blocked page was written before safety block: %s", out)
}
}
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, code int) {
t.Helper()
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != category || p.Subtype != subtype || p.Code != code {
t.Fatalf("problem = %s/%s/%d, want %s/%s/%d", p.Category, p.Subtype, p.Code, category, subtype, code)
}
}
func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
for _, tt := range []struct {
name string

View File

@@ -33,12 +33,9 @@ func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
if got := output.ExitCodeOf(err); got != 1 {
t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got)
}
var bare *output.ExitError
var bare *output.BareError
if !errors.As(err, &bare) {
t.Fatalf("expected *output.ExitError (ErrBare), got %T: %v", err, err)
}
if bare.Detail != nil {
t.Errorf("ErrBare must carry no Detail (no envelope), got %+v", bare.Detail)
t.Fatalf("expected *output.BareError (ErrBare), got %T: %v", err, err)
}
if stderr.Len() != 0 {

View File

@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -59,7 +60,7 @@ func authListRun(opts *ListOptions) error {
// keep the same contract here. We still want the hint to be
// workspace-aware, so we pull the message+hint out of
// NotConfiguredError() instead of hard-coding it.
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
if cfgErr.Hint != "" {

View File

@@ -878,7 +878,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
// contract that when --json is set and pollDeviceToken returns OK=false,
// stdout carries the structured authorization_failed event and stderr is
// NOT polluted with a typed envelope. The returned error is a bare
// ExitError with ExitAuth so the dispatcher only propagates the exit code
// BareError with ExitAuth so the dispatcher only propagates the exit code
// without emitting a second envelope on top of the JSON event.
func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
keyring.MockInit()
@@ -945,16 +945,13 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr)
}
// Returned error must be the bare *output.ExitError signal (no envelope).
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
// Returned error must be the bare *output.BareError signal (no envelope).
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAuth {
t.Fatalf("ExitError.Code = %d, want %d", exitErr.Code, output.ExitAuth)
}
if exitErr.Detail != nil {
t.Errorf("ExitError.Detail should be nil for bare signal, got: %+v", exitErr.Detail)
if bareErr.Code != output.ExitAuth {
t.Fatalf("BareError.Code = %d, want %d", bareErr.Code, output.ExitAuth)
}
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -33,9 +34,13 @@ import (
type BuildOption func(*buildConfig)
type buildConfig struct {
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
skipPlugins bool
skipStrictMode bool
skipService bool
serviceCatalog *apicatalog.Catalog
}
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
@@ -75,6 +80,41 @@ func HideProfile(hide bool) BuildOption {
}
}
// WithoutPlugins builds only repository-owned commands. It is intended for
// inspection tools that need a deterministic command tree.
func WithoutPlugins() BuildOption {
return func(c *buildConfig) {
c.skipPlugins = true
}
}
// WithoutStrictMode builds the complete repository-owned command tree without
// applying user/profile strict-mode pruning. It is intended for offline
// inspection tools, not production execution.
func WithoutStrictMode() BuildOption {
return func(c *buildConfig) {
c.skipStrictMode = true
}
}
// WithoutServiceCommands builds only hand-authored commands. It is intended for
// repository quality gates that should not depend on the remote OpenAPI
// metadata command surface.
func WithoutServiceCommands() BuildOption {
return func(c *buildConfig) {
c.skipService = true
}
}
// WithServiceCatalog builds generated service commands from a specific metadata
// catalog. It is intended for offline inspection tools that need deterministic
// embedded metadata while production execution keeps using the runtime catalog.
func WithServiceCatalog(catalog apicatalog.Catalog) BuildOption {
return func(c *buildConfig) {
c.serviceCatalog = &catalog
}
}
// Build constructs the full command tree. It also installs registered
// plugins and emits the Startup lifecycle event during assembly --
// so Plugin.On(Startup) handlers run even if the returned command is
@@ -156,15 +196,26 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(skill.NewCmdSkill(f))
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
if !cfg.skipService {
if cfg.serviceCatalog != nil {
service.RegisterServiceCommandsFromCatalog(ctx, rootCmd, f, *cfg.serviceCatalog)
} else {
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
}
}
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
installUnknownSubcommandGuard(rootCmd)
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
pruneForStrictMode(rootCmd, mode)
}
if cfg.skipPlugins {
recordInventory(nil)
return f, rootCmd, nil
}
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
if installErr != nil {
installPluginInstallErrorGuard(rootCmd, installErr)

46
cmd/build_test.go Normal file
View File

@@ -0,0 +1,46 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
func TestBuildWithoutPluginsStillBuildsBuiltinCommands(t *testing.T) {
root := Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
if root == nil {
t.Fatal("Build returned nil root")
}
if findCommand(root, "api") == nil {
t.Fatal("builtin api command missing")
}
if findCommand(root, "docs +fetch") == nil {
t.Fatal("builtin docs +fetch shortcut missing")
}
}
func findCommand(root *cobra.Command, path string) *cobra.Command {
parts := strings.Fields(path)
cmd := root
for _, part := range parts {
var next *cobra.Command
for _, child := range cmd.Commands() {
if child.Name() == part {
next = child
break
}
}
if next == nil {
return nil
}
cmd = next
}
return cmd
}

View File

@@ -4,8 +4,7 @@
package completion
import (
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
@@ -32,7 +31,9 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
case "powershell":
return root.GenPowerShellCompletionWithDesc(out)
default:
return fmt.Errorf("unsupported shell: %s", args[0])
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported shell: %s", args[0]).
WithHint("supported shells: bash, zsh, fish, powershell")
}
},
}

View File

@@ -212,10 +212,7 @@ func finalizeSource(opts *BindOptions) (string, error) {
if opts.IsTUI && !opts.langExplicit {
lang, err := promptLangSelection()
if err != nil {
if err == huh.ErrUserAborted {
return "", output.ErrBare(1)
}
return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
return "", langSelectionError(err)
}
opts.Lang = string(lang)
opts.UILang = lang

View File

@@ -20,35 +20,29 @@ import (
"github.com/larksuite/cli/internal/output"
)
// assertExitError checks the full structured error in one assertion. It
// accepts both *output.ExitError (used by output.ErrWithHint) and the
// typed errors (ValidationError, ConfigError) — they normalize to the same
// wantDetail fields. The wantDetail.Type is matched against the typed error's
// Category string ("validation", "config", etc.).
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
// wantErrDetail is the normalized comparison shape for a typed error's wire
// fields: Type is the error's Category string ("validation", "config", ...),
// alongside Message and Hint.
type wantErrDetail struct {
Type string
Message string
Hint string
}
// assertExitError checks the full structured error in one assertion against a
// typed error (ValidationError or ConfigError), normalizing its Category /
// Message / Hint to wantDetail.
func assertExitError(t *testing.T, err error, wantCode int, wantDetail wantErrDetail) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
if exitErr.Code != wantCode {
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
}
if exitErr.Detail == nil {
t.Fatal("expected non-nil error detail")
}
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
}
return
}
var ve *errs.ValidationError
if errors.As(err, &ve) {
if got := output.ExitCodeOf(err); got != wantCode {
t.Errorf("exit code = %d, want %d", got, wantCode)
}
gotDetail := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
gotDetail := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
if !reflect.DeepEqual(gotDetail, wantDetail) {
t.Errorf("validation error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
}
@@ -59,13 +53,13 @@ func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.Er
if got := output.ExitCodeOf(err); got != wantCode {
t.Errorf("exit code = %d, want %d", got, wantCode)
}
gotDetail := output.ErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
gotDetail := wantErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
if !reflect.DeepEqual(gotDetail, wantDetail) {
t.Errorf("config error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
}
return
}
t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
t.Fatalf("error type = %T, want *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
}
// assertEnvelope decodes stdout and checks it matches want exactly — every key
@@ -179,15 +173,21 @@ func TestConfigBindRun_InvalidLang(t *testing.T) {
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
if valErr.Param != "--lang" {
t.Errorf("param = %q, want %q", valErr.Param, "--lang")
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", err.Error())
}
})
}
@@ -365,7 +365,7 @@ func TestConfigBindRun_InvalidSource(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "invalid"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `invalid --source "invalid"; valid values: openclaw, hermes, lark-channel`,
})
@@ -382,7 +382,7 @@ func TestConfigBindRun_MissingSourceNonTTY(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
// TestFactory has IsTerminal=false by default
err := configBindRun(&BindOptions{Factory: f, Source: ""})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: "cannot determine Agent source: no --source flag and no Agent environment detected",
Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context",
@@ -421,7 +421,7 @@ func TestConfigBindRun_SourceEnvMismatch_OpenClawFlagInHermesEnv(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--source "openclaw" does not match detected Agent environment (hermes)`,
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
@@ -437,7 +437,7 @@ func TestConfigBindRun_SourceEnvMismatch_HermesFlagInOpenClawEnv(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--source "hermes" does not match detected Agent environment (openclaw)`,
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
@@ -566,7 +566,7 @@ func TestConfigBindRun_HermesMissingEnvFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
Hint: "verify Hermes is installed and configured at " + envPath,
@@ -584,7 +584,7 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
Hint: "verify OpenClaw is installed and configured",
@@ -731,7 +731,7 @@ func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`,
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
@@ -750,7 +750,7 @@ func TestConfigBindRun_LarkChannelMissingFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
Hint: "verify lark-channel-bridge is installed and configured",
@@ -770,7 +770,7 @@ func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "accounts.app.id missing in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential",
@@ -789,7 +789,7 @@ func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "accounts.app.secret is empty in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential",
@@ -835,17 +835,19 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
t.Fatal("expected error for unbound workspace")
}
// Should be a structured ConfigError suggesting config bind, not config init.
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
// Config errors share ExitAuth (3); the workspace is detected but no
// binding exists yet, which is a config error.
if cfgErr.Code != output.ExitAuth {
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
}
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
// The workspace name stays out of the wire subtype; it only appears in
// the message.
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "openclaw context detected") {
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
@@ -1187,7 +1189,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
// iterates a map — ordering is non-deterministic. DeepEqual inline against
// each accepted variant so every ErrDetail field (Type, Code, Message,
// Hint, ConsoleURL, Detail, and any future addition) is still compared.
base := output.ErrDetail{
base := wantErrDetail{
Type: "validation",
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
}
@@ -1203,7 +1205,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err)
}
got := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
got := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
if !reflect.DeepEqual(got, wantWorkFirst) && !reflect.DeepEqual(got, wantPersonalFirst) {
t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
got, wantWorkFirst, wantPersonalFirst)
@@ -1230,7 +1232,7 @@ func TestConfigBindRun_OpenClawMultiAccount_WrongAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only_one",
@@ -1250,7 +1252,7 @@ func TestConfigBindRun_InvalidIdentity(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes", Identity: "invalid"})
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `invalid --identity "invalid"; valid values: bot-only, user-default`,
})
@@ -1536,7 +1538,7 @@ func TestConfigBindRun_HermesMissingAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "FEISHU_APP_ID not found in " + envPath,
Hint: "run 'hermes setup' to configure Feishu credentials",
@@ -1556,7 +1558,7 @@ func TestConfigBindRun_HermesMissingAppSecret(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "FEISHU_APP_SECRET not found in " + envPath,
Hint: "run 'hermes setup' to configure Feishu credentials",
@@ -1582,7 +1584,7 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "openclaw.json missing channels.feishu section",
Hint: "configure Feishu in OpenClaw first",
@@ -1610,7 +1612,7 @@ func TestConfigBindRun_OpenClawEmptyAppSecret(t *testing.T) {
openclawPath := filepath.Join(openclawDir, "openclaw.json")
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
Hint: "configure channels.feishu.appSecret in openclaw.json",
@@ -1672,7 +1674,7 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId in openclaw.json",

View File

@@ -51,7 +51,7 @@ func assertCandidate(t *testing.T, got *Candidate, want Candidate) {
func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId in openclaw.json",
@@ -64,7 +64,7 @@ func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) {
// even before it has a bespoke error message.
b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
assertExitError(t, err, output.ExitAuth, wantErrDetail{
Type: "config",
Message: "hermes: no app configured",
})
@@ -100,7 +100,7 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
{AppID: "cli_home", Label: "home"},
}
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
@@ -117,7 +117,7 @@ func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) {
{AppID: "cli_home", Label: "home"},
}
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
@@ -152,7 +152,7 @@ func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{{AppID: "cli_only"}}
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
assertExitError(t, err, output.ExitValidation, wantErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only",

View File

@@ -12,6 +12,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -92,16 +93,16 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
t.Fatal("expected error")
}
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
// Config errors share ExitAuth (3), not ExitValidation.
if cfgErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
}
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
if cfgErr.Subtype != errs.SubtypeNotConfigured || cfgErr.Message != "not configured" {
t.Fatalf("detail = %+v, want not_configured/not configured", cfgErr)
}
}
@@ -233,15 +234,21 @@ func TestConfigInitCmd_InvalidLang(t *testing.T) {
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
if valErr.Param != "--lang" {
t.Errorf("param = %q, want %q", valErr.Param, "--lang")
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", err.Error())
}
})
}
@@ -385,8 +392,38 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T
if err == nil {
t.Fatal("expected conflict error")
}
if !strings.Contains(err.Error(), "conflicts with existing appId") {
t.Fatalf("error = %v, want conflict with existing appId", err)
// A name/appId conflict is user input — a typed validation error naming the
// offending flag, not a system storage failure.
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("error type = %T, want *errs.ValidationError; err=%v", err, err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if verr.Param != "--name" {
t.Errorf("param = %q, want --name", verr.Param)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", output.ExitCodeOf(err), output.ExitValidation)
}
if !strings.Contains(verr.Message, "conflicts with existing appId") {
t.Errorf("message = %q, want conflict description", verr.Message)
}
}
// TestWrapSaveConfigError_PassesTypedValidationThrough pins that a user-input
// validation error (e.g. the --name conflict) is not reclassified as an
// internal storage failure on its way up through the save call sites.
func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) {
conflict := errs.NewValidationError(errs.SubtypeInvalidArgument, "name conflict").WithParam("--name")
var verr *errs.ValidationError
if !errors.As(wrapSaveConfigError(conflict), &verr) {
t.Fatalf("typed validation must pass through unchanged, got %T", wrapSaveConfigError(conflict))
}
var ierr *errs.InternalError
if !errors.As(wrapSaveConfigError(errors.New("disk full")), &ierr) || ierr.Subtype != errs.SubtypeStorage {
t.Fatalf("untyped failure must become internal/storage")
}
}

View File

@@ -6,13 +6,11 @@ package config
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
@@ -127,12 +125,9 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
if ws.IsLocal() {
return nil
}
return &core.ConfigError{
Code: 2,
Type: ws.Display(),
Message: fmt.Sprintf("config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
Hint: "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.",
}
return errs.NewConfigError(errs.SubtypeNotConfigured,
"config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()).
WithHint("see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.")
}
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
@@ -183,6 +178,20 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
}
// wrapSaveConfigError passes an already-typed error (e.g. the --name conflict
// validation error from saveAsProfile) through unchanged, and classifies any
// other failure as an internal storage error. Without the passthrough a user
// input error would surface to agents as a system storage failure.
func wrapSaveConfigError(err error) error {
if err == nil {
return nil
}
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
// saveAsProfile appends or updates a named profile in the config.
// If a profile with the same name exists, it updates it; otherwise appends.
// When updating, cleans up old keychain secrets if AppId changed.
@@ -207,7 +216,9 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
} else {
if findAppIndexByAppID(multi, profileName) >= 0 {
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"profile name %q conflicts with existing appId", profileName).
WithParam("--name")
}
// Append new profile
multi.Apps = append(multi.Apps, core.AppConfig{
@@ -249,8 +260,8 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
// wrapUpdateExistingProfileErr classifies the error returned by
// updateExistingProfileWithoutSecret. Typed errors (e.g. *errs.ValidationError
// for blank-input) pass through unchanged so their exit code semantics
// survive; legacy *output.ExitError also passes through; everything else
// (filesystem, keychain, etc.) is wrapped as InternalError.
// survive; everything else (filesystem, keychain, etc.) is wrapped as
// InternalError.
func wrapUpdateExistingProfileErr(err error) error {
if err == nil {
return nil
@@ -258,10 +269,6 @@ func wrapUpdateExistingProfileErr(err error) error {
if errs.IsTyped(err) {
return err
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err)
}
@@ -336,7 +343,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return wrapSaveConfigError(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
@@ -353,10 +360,7 @@ func configInitRun(opts *ConfigInitOptions) error {
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
lang, err := promptLangSelection()
if err != nil {
if err == huh.ErrUserAborted {
return output.ErrBare(1)
}
return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
return langSelectionError(err)
}
opts.Lang = string(lang)
opts.UILang = lang
@@ -379,7 +383,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return wrapSaveConfigError(err)
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
@@ -409,7 +413,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return wrapSaveConfigError(err)
}
} else if result.Mode == "existing" && result.AppID != "" {
// Existing app with unchanged secret — update app ID and brand only
@@ -514,7 +518,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
return wrapSaveConfigError(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)

View File

@@ -8,7 +8,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/errs"
)
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
@@ -26,12 +26,15 @@ func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
if err == nil {
t.Fatal("expected refusal in OpenClaw context, got nil")
}
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "openclaw") {
t.Errorf("message must name the openclaw workspace; got %q", cfgErr.Message)
}
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint)
@@ -48,12 +51,15 @@ func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
if err == nil {
t.Fatal("expected refusal in Hermes context, got nil")
}
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
t.Fatalf("error type = %T, want *errs.ConfigError", err)
}
if cfgErr.Type != "hermes" {
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "hermes") {
t.Errorf("message must name the hermes workspace; got %q", cfgErr.Message)
}
}

View File

@@ -4,10 +4,14 @@
package config
import (
"errors"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
type initMsg struct {
@@ -97,3 +101,12 @@ func promptLangSelection() (i18n.Lang, error) {
}
return lang, nil
}
// langSelectionError maps a promptLangSelection failure to its exit surface:
// user abort exits bare with code 1; any other failure is internal.
func langSelectionError(err error) error {
if errors.Is(err, huh.ErrUserAborted) {
return output.ErrBare(1)
}
return errs.NewInternalError(errs.SubtypeUnknown, "language selection failed: %v", err).WithCause(err)
}

View File

@@ -65,8 +65,8 @@ func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t
// wrapUpdateExistingProfileErr is the caller-side classifier for the error
// returned by updateExistingProfileWithoutSecret. It must preserve typed-error
// exit semantics (regression: typed ValidationError was being downgraded to
// InternalError by the legacy *output.ExitError-only passthrough).
// exit semantics: a typed ValidationError must keep ExitValidation rather than
// being downgraded to InternalError.
func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) {
if got := wrapUpdateExistingProfileErr(nil); got != nil {
@@ -90,18 +90,6 @@ func TestWrapUpdateExistingProfileErr_TypedValidationErrorPreserved(t *testing.T
}
}
func TestWrapUpdateExistingProfileErr_LegacyExitErrorPreserved(t *testing.T) {
in := &output.ExitError{Code: 7, Err: errors.New("legacy")}
got := wrapUpdateExistingProfileErr(in)
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected *output.ExitError to pass through, got %T: %v", got, got)
}
if exitErr.Code != 7 {
t.Errorf("Code = %d, want 7", exitErr.Code)
}
}
func TestWrapUpdateExistingProfileErr_UntypedErrorBecomesInternal(t *testing.T) {
in := fmt.Errorf("disk full")
got := wrapUpdateExistingProfileErr(in)

View File

@@ -14,6 +14,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -94,7 +95,7 @@ func doctorRun(opts *DoctorOptions) error {
// underlying problem is still visible.
msg, hint := err.Error(), ""
if errors.Is(err, os.ErrNotExist) {
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
msg, hint = cfgErr.Message, cfgErr.Hint
}
@@ -108,7 +109,7 @@ func doctorRun(opts *DoctorOptions) error {
cfg, err := f.Config()
if err != nil {
hint := ""
var cfgErr *core.ConfigError
var cfgErr *errs.ConfigError
if errors.As(err, &cfgErr) {
hint = cfgErr.Hint
}

View File

@@ -15,7 +15,6 @@ import (
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
@@ -49,32 +48,6 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
authErr.Hint += "\n" + scopeHint
}
// enrichMissingScopeError appends a "current command requires scope(s): X"
// hint to a legacy *output.ExitError when the underlying error carries the
// need_user_authorization marker AND the current command declares scopes
// locally.
//
// Deprecated: enrichment for the legacy envelope; the typed path is
// applyNeedAuthorizationHint above.
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr == nil || exitErr.Detail == nil {
return
}
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
return
}
scopes := resolveDeclaredScopesForCurrentCommand(f)
if len(scopes) == 0 {
return
}
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
if exitErr.Detail.Hint == "" {
exitErr.Detail.Hint = scopeHint
return
}
exitErr.Detail.Hint += "\n" + scopeHint
}
// resolveDeclaredScopesForCurrentCommand returns the scopes declared by the
// current command for the resolved identity, checking shortcuts first and then
// service methods from local registry metadata.

View File

@@ -8,7 +8,7 @@ import (
"regexp"
)
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen when adding brands in consoleScopeGrantURL.
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen the host alternation when adding brands.
var authURLPattern = regexp.MustCompile(`https?://open\.(?:feishu\.cn|larksuite\.com)/app/[^/\s"']+/auth\?q=[^\s"'<>]+`)
// describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.

View File

@@ -4,21 +4,117 @@
package event
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
)
// consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
host, appID, strings.Join(scopes, ","))
// Landing-page contract for the scan-to-enable deep link, verified against the
// open platform: {open-host}/page/launcher?clientID=<appID>&addons=<encoded>.
// Note the param is camelCase "clientID" (not snake_case), and the value is the
// consuming app's own ID. Centralized so it can be corrected in one place.
const (
addonsLandingPath = "/page/launcher"
addonsClientIDParam = "clientID"
)
// ManifestAddons mirrors the 5 public manifest sections the launcher page accepts.
// Encoded form: JSON -> gzip -> base64url(no padding).
type ManifestAddons struct {
Scopes *AddonsScopes `json:"scopes,omitempty"`
Events *AddonsEvents `json:"events,omitempty"`
Callbacks *AddonsCallbacks `json:"callbacks,omitempty"`
}
// consoleEventSubscriptionURL points at the app's event subscription console page.
func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s/app/%s/event", host, appID)
type AddonsScopes struct {
Tenant []string `json:"tenant"`
User []string `json:"user"`
}
type AddonsEvents struct {
Items AddonsEventItems `json:"items"`
}
type AddonsEventItems struct {
Tenant []string `json:"tenant"`
User []string `json:"user"`
}
type AddonsCallbacks struct {
Items []string `json:"items"`
}
// encodeAddons: JSON -> gzip -> base64url(no padding). Matches the front-end decode chain.
func encodeAddons(a ManifestAddons) (string, error) {
raw, err := json.Marshal(a)
if err != nil {
return "", err
}
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
if _, err := gw.Write(raw); err != nil {
return "", err
}
if err := gw.Close(); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf.Bytes()), nil
}
// consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks.
func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) {
encoded, err := encodeAddons(a)
if err != nil {
return "", err
}
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
}
// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
func consoleLandingURL(brand core.LarkBrand, appID string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)
}
// addonsHintURL returns the scan URL, degrading to the bare landing page on encode error.
func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string {
url, err := consoleAddonsURL(brand, appID, a)
if err != nil {
return consoleLandingURL(brand, appID)
}
return url
}
// missingScopeAddons routes missing scopes into the identity-appropriate section.
// The unused side is an empty (non-nil) slice so JSON encodes [] not null —
// the addons spec treats a missing tenant/user as an empty array.
func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons {
s := &AddonsScopes{Tenant: []string{}, User: []string{}}
if identity.IsBot() {
s.Tenant = missing
} else {
s.User = missing
}
return ManifestAddons{Scopes: s}
}
// missingSubscriptionAddons routes missing events/callbacks into the right section.
// Like missingScopeAddons, unused event sides stay [] (not null) per the addons spec.
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity core.Identity, missing []string) ManifestAddons {
if subType == eventlib.SubTypeCallback {
return ManifestAddons{Callbacks: &AddonsCallbacks{Items: missing}}
}
ev := &AddonsEvents{Items: AddonsEventItems{Tenant: []string{}, User: []string{}}}
if identity.IsBot() {
ev.Items.Tenant = missing
} else {
ev.Items.User = missing
}
return ManifestAddons{Events: ev}
}

View File

@@ -4,33 +4,109 @@
package event
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"io"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
)
func TestConsoleScopeGrantURL_Feishu(t *testing.T) {
got := consoleScopeGrantURL(core.BrandFeishu, "cli_XXXXXXXXXXXXXXXX", []string{
"im:message:readonly",
"im:message.group_at_msg",
})
want := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=im:message:readonly,im:message.group_at_msg&op_from=openapi&token_type=tenant"
if got != want {
t.Errorf("url\n got: %s\nwant: %s", got, want)
func decodeAddons(t *testing.T, encoded string) ManifestAddons {
t.Helper()
gz, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("base64url decode: %v", err)
}
zr, err := gzip.NewReader(bytes.NewReader(gz))
if err != nil {
t.Fatalf("gzip reader: %v", err)
}
raw, err := io.ReadAll(zr)
if err != nil {
t.Fatalf("gunzip: %v", err)
}
var a ManifestAddons
if err := json.Unmarshal(raw, &a); err != nil {
t.Fatalf("json: %v", err)
}
return a
}
func TestEncodeAddons_RoundTrip(t *testing.T) {
in := ManifestAddons{Scopes: &AddonsScopes{Tenant: []string{"im:message"}}}
encoded, err := encodeAddons(in)
if err != nil {
t.Fatalf("encode: %v", err)
}
for _, r := range encoded {
if !(r == '-' || r == '_' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
t.Fatalf("encoded contains non-base64url char %q in %q", r, encoded)
}
}
out := decodeAddons(t, encoded)
if out.Scopes == nil || len(out.Scopes.Tenant) != 1 || out.Scopes.Tenant[0] != "im:message" {
t.Errorf("roundtrip mismatch: %+v", out)
}
}
func TestConsoleScopeGrantURL_LarkBrand(t *testing.T) {
got := consoleScopeGrantURL(core.BrandLark, "cli_x", []string{"im:message"})
want := "https://open.larksuite.com/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant"
if got != want {
t.Errorf("url\n got: %s\nwant: %s", got, want)
func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
if err != nil {
t.Fatalf("url: %v", err)
}
host := core.ResolveEndpoints(core.BrandFeishu).Open
prefix := host + "/page/launcher?clientID=cli_x&addons="
if !strings.HasPrefix(url, prefix) {
t.Errorf("url = %q, want prefix %q", url, prefix)
}
out := decodeAddons(t, strings.TrimPrefix(url, prefix))
if out.Callbacks == nil || len(out.Callbacks.Items) != 1 || out.Callbacks.Items[0] != "card.action.trigger" {
t.Errorf("decoded callbacks mismatch: %+v", out)
}
}
func TestConsoleScopeGrantURL_EmptyBrandDefaultsFeishu(t *testing.T) {
got := consoleScopeGrantURL("", "cli_x", []string{"im:message"})
if got != "https://open.feishu.cn/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant" {
t.Errorf("unexpected url: %s", got)
func TestMissingScopeAddons_ByIdentity(t *testing.T) {
bot := missingScopeAddons(core.AsBot, []string{"im:message"})
if bot.Scopes == nil || len(bot.Scopes.Tenant) != 1 || len(bot.Scopes.User) != 0 {
t.Errorf("bot scopes = %+v, want tenant-only", bot.Scopes)
}
user := missingScopeAddons(core.AsUser, []string{"im:message"})
if user.Scopes == nil || len(user.Scopes.User) != 1 || len(user.Scopes.Tenant) != 0 {
t.Errorf("user scopes = %+v, want user-only", user.Scopes)
}
}
func TestMissingSubscriptionAddons_EventVsCallback(t *testing.T) {
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"})
if ev.Events == nil || len(ev.Events.Items.Tenant) != 1 {
t.Errorf("event addons = %+v, want events.items.tenant", ev.Events)
}
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, core.AsBot, []string{"card.action.trigger"})
if cb.Callbacks == nil || len(cb.Callbacks.Items) != 1 || cb.Events != nil {
t.Errorf("callback addons = %+v, want callbacks.items only", cb)
}
}
func TestMissingAddons_EncodeEmptyArraysNotNull(t *testing.T) {
// Unused identity sides must encode as [] (not null) so the launcher page's
// shape validation treats them as "缺省 -> 空数组" per the addons spec.
cases := []ManifestAddons{
missingScopeAddons(core.AsBot, []string{"im:message"}),
missingScopeAddons(core.AsUser, []string{"im:message"}),
missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"}),
}
for i, a := range cases {
raw, err := json.Marshal(a)
if err != nil {
t.Fatalf("case %d marshal: %v", i, err)
}
if bytes.Contains(raw, []byte("null")) {
t.Errorf("case %d encodes a null array, want []: %s", i, raw)
}
}
}

View File

@@ -146,14 +146,28 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
fmt.Fprintln(preflightErrOut, "[event] skipped console precheck: app has no published version")
}
// Callback subscriptions live in application/get, not app_versions; fetch the
// callback 底账 only for callback-type EventKeys. Weak dependency: on error,
// leave subscribedCallbacks nil so the callback precheck skips.
var subscribedCallbacks []string
if keyDef.SubscriptionType == eventlib.SubTypeCallback {
cbs, cbErr := appmeta.FetchSubscribedCallbacks(cmd.Context(), botRuntime, cfg.AppID)
if cbErr != nil {
fmt.Fprintf(preflightErrOut, "[event] skipped console precheck: %s\n", describeAppMetaErr(cbErr))
} else {
subscribedCallbacks = cbs
}
}
pf := &preflightCtx{
factory: f,
appID: cfg.AppID,
brand: cfg.Brand,
eventKey: eventKey,
identity: identity,
keyDef: keyDef,
appVer: appVer,
factory: f,
appID: cfg.AppID,
brand: cfg.Brand,
eventKey: eventKey,
identity: identity,
keyDef: keyDef,
appVer: appVer,
subscribedCallbacks: subscribedCallbacks,
}
if err := preflightEventTypes(pf); err != nil {
return err
@@ -229,6 +243,9 @@ type preflightCtx struct {
identity core.Identity
keyDef *eventlib.KeyDefinition
appVer *appmeta.AppVersion
// subscribedCallbacks is the application/get 底账 for callback-type EventKeys;
// nil means "not fetched / unavailable" → callback precheck skips (weak dependency).
subscribedCallbacks []string
}
// preflightScopes compares required scopes against session-available scopes (user: UAT stored; bot: appVer.TenantScopes).
@@ -266,46 +283,66 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
WithIdentity(string(pf.identity)).
WithMissingScopes(missing...).
WithHint("%s", scopeRemediationHint(pf.identity, missing, pf.appID, pf.brand))
WithHint("%s", scopeRemediationHint(pf.brand, pf.appID, pf.identity, missing))
}
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
func scopeRemediationHint(identity core.Identity, missing []string, appID string, brand core.LarkBrand) string {
// Bot: the scan-to-enable link adds the scopes to the app manifest, after which
// the tenant token carries them. User: the scan link only updates the app
// manifest — the user's own token still lacks the scopes until it is
// re-authorized — so direct the user to re-login instead.
func scopeRemediationHint(brand core.LarkBrand, appID string, identity core.Identity, missing []string) string {
if identity.IsBot() {
return fmt.Sprintf(
"grant these scopes and publish a new app version at: %s",
consoleScopeGrantURL(brand, appID, missing),
)
return fmt.Sprintf("grant these scopes by scanning: %s",
addonsHintURL(brand, appID, missingScopeAddons(identity, missing)))
}
return fmt.Sprintf(
"run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
strings.Join(missing, " "),
)
strings.Join(missing, " "))
}
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed in the app's current published version.
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed
// in the app's console 底账 — published app_versions for event subscriptions,
// application/get subscribed_callbacks for callback subscriptions.
func preflightEventTypes(pf *preflightCtx) error {
if pf.appVer == nil || len(pf.keyDef.RequiredConsoleEvents) == 0 {
if len(pf.keyDef.RequiredConsoleEvents) == 0 {
return nil
}
subscribed := make(map[string]bool, len(pf.appVer.EventTypes))
for _, t := range pf.appVer.EventTypes {
subscribed[t] = true
var subscribed []string
noun := "event types"
if pf.keyDef.SubscriptionType == eventlib.SubTypeCallback {
if pf.subscribedCallbacks == nil {
return nil
}
subscribed = pf.subscribedCallbacks
noun = "callbacks"
} else {
if pf.appVer == nil {
return nil
}
subscribed = pf.appVer.EventTypes
}
have := make(map[string]bool, len(subscribed))
for _, t := range subscribed {
have[t] = true
}
var missing []string
for _, t := range pf.keyDef.RequiredConsoleEvents {
if !subscribed[t] {
if !have[t] {
missing = append(missing, t)
}
}
if len(missing) == 0 {
return nil
}
url := addonsHintURL(pf.brand, pf.appID, missingSubscriptionAddons(pf.keyDef.SubscriptionType, pf.identity, missing))
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
"EventKey %s requires event types not subscribed in console: %s",
pf.keyDef.Key, strings.Join(missing, ", ")).
WithHint("subscribe these events and publish a new app version at: %s",
consoleEventSubscriptionURL(pf.brand, pf.appID))
"EventKey %s requires %s not subscribed in console: %s",
pf.keyDef.Key, noun, strings.Join(missing, ", ")).
WithHint("subscribe these %s by scanning: %s", noun, url)
}
// sanitizeOutputDir rejects absolute/parent-escaping paths and ~ (SafeOutputPath treats it as a literal dir name).
@@ -349,9 +386,9 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
// Sentinels for errors.Is checks; call sites wrap them as typed ValidationError causes.
var (
errInvalidParamFormat = errors.New("invalid --param format")
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion")
errOutputDirUnsafe = errors.New("unsafe --output-dir")
errInvalidParamFormat = errors.New("invalid --param format") //nolint:forbidigo // sentinel, typed at call sites
errOutputDirTilde = errors.New("--output-dir does not support ~ expansion") //nolint:forbidigo // sentinel, typed at call sites
errOutputDirUnsafe = errors.New("unsafe --output-dir") //nolint:forbidigo // sentinel, typed at call sites
)
func parseParams(raw []string) (map[string]string, error) {

View File

@@ -270,15 +270,15 @@ func TestExitForOrphan(t *testing.T) {
if err == nil {
t.Fatal("flag on + orphan → expected error, got nil")
}
var exit *output.ExitError
var exit *output.BareError
if !errorAs(err, &exit) || exit.Code != output.ExitValidation {
t.Errorf("exit code = %v, want ExitValidation", err)
}
}
func errorAs(err error, target interface{}) bool {
if e, ok := err.(*output.ExitError); ok {
if t, ok := target.(**output.ExitError); ok {
if e, ok := err.(*output.BareError); ok {
if t, ok := target.(**output.BareError); ok {
*t = e
return true
}

View File

@@ -97,9 +97,9 @@ func TestPreflightEventTypes_MissingBlocks(t *testing.T) {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryValidation, errs.SubtypeFailedPrecondition)
}
wantURL := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/event"
wantURL := "https://open.feishu.cn/page/launcher?clientID=cli_XXXXXXXXXXXXXXXX&addons="
if !strings.Contains(p.Hint, wantURL) {
t.Errorf("hint missing subscription URL %q\ngot: %s", wantURL, p.Hint)
t.Errorf("hint missing scan link %q\ngot: %s", wantURL, p.Hint)
}
}
@@ -157,9 +157,8 @@ func TestPreflightScopes_Bot_MissingBlocks(t *testing.T) {
}
hint := permErr.Hint
wantSubstrings := []string{
"https://open.feishu.cn/app/cli_x/auth?q=",
"im:message.group_at_msg",
"token_type=tenant",
"grant these scopes by scanning: ",
"https://open.feishu.cn/page/launcher?clientID=cli_x&addons=",
}
for _, want := range wantSubstrings {
if !strings.Contains(hint, want) {
@@ -174,3 +173,109 @@ func TestPreflightScopes_NoRequiredScopes_SkipsCheck(t *testing.T) {
t.Fatalf("no required scopes means nothing to verify, got: %v", err)
}
}
func TestPreflightEventTypes_CallbackMissing(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{"profile.view.get"},
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
err := preflightEventTypes(pf)
if err == nil {
t.Fatal("expected error for missing callback")
}
if !strings.Contains(err.Error(), "callbacks not subscribed") {
t.Errorf("error = %q, want mention of 'callbacks not subscribed'", err.Error())
}
if !strings.Contains(err.Error(), "card.action.trigger") {
t.Errorf("error should name the missing callback, got: %q", err.Error())
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("problem = %v, want validation/failed_precondition", p)
}
}
func TestPreflightEventTypes_CallbackSkippedWhenNil(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: nil, // fetch 失败/拿不到 -> 弱依赖跳过
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
if err := preflightEventTypes(pf); err != nil {
t.Errorf("expected skip (nil), got %v", err)
}
}
func TestPreflightEventTypes_CallbackEmptyReportsMissing(t *testing.T) {
// fetched but zero callbacks subscribed (non-nil empty) is a definitive
// console state: a required callback IS missing and must be reported,
// not skipped as a weak dependency.
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{}, // fetched, none subscribed
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
err := preflightEventTypes(pf)
if err == nil {
t.Fatal("expected error for missing callback when none are subscribed")
}
if !strings.Contains(err.Error(), "card.action.trigger") {
t.Errorf("error should name the missing callback, got: %q", err.Error())
}
}
func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
pf := &preflightCtx{
appID: "cli_x",
brand: core.BrandFeishu,
eventKey: "test.cb",
identity: core.AsBot,
subscribedCallbacks: []string{"card.action.trigger", "profile.view.get"},
keyDef: &eventlib.KeyDefinition{
Key: "test.cb",
SubscriptionType: eventlib.SubTypeCallback,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
if err := preflightEventTypes(pf); err != nil {
t.Errorf("all callbacks subscribed, unexpected error: %v", err)
}
}
func TestScopeRemediationHint_ByIdentity(t *testing.T) {
// bot: scan-to-enable link (adds scopes to app manifest)
bot := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsBot, []string{"im:message"})
if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") {
t.Errorf("bot hint should give the scan link, got: %s", bot)
}
// user: re-login (scan link cannot grant scopes to the user's own token)
user := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsUser, []string{"im:message"})
if !strings.Contains(user, "auth login --scope") {
t.Errorf("user hint should direct to auth login, got: %s", user)
}
if strings.Contains(user, "/page/launcher") {
t.Errorf("user hint must NOT use the scan link, got: %s", user)
}
}

View File

@@ -19,12 +19,12 @@ func TestExitForOrphan_Orphan(t *testing.T) {
if err == nil {
t.Fatal("expected error when failOnOrphan=true and orphan present")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("Code = %d, want %d", exitErr.Code, output.ExitValidation)
if bareErr.Code != output.ExitValidation {
t.Errorf("Code = %d, want %d", bareErr.Code, output.ExitValidation)
}
}

View File

@@ -5,10 +5,10 @@ package cmd
import (
"errors"
"slices"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -40,31 +40,65 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
c.Flags().Bool("dry-run", false, "")
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if !strings.Contains(exitErr.Detail.Hint, "--range") {
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
detail, _ := exitErr.Detail.Detail.(map[string]any)
valid, _ := detail["valid_flags"].([]string)
if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") {
t.Errorf("valid_flags should list find & range, got %v", valid)
// The offending flag is carried structurally on Params (replaces the
// legacy detail map) and named in the message.
if len(verr.Params) != 1 || verr.Params[0].Name != "--rang" {
t.Errorf("Params = %v, want one entry named --rang", verr.Params)
}
if len(verr.Params) == 1 && verr.Params[0].Reason == "" {
t.Error("Params[0].Reason must explain the rejection")
}
if !strings.Contains(verr.Message, "--rang") {
t.Errorf("message should name the offending flag, got %q", verr.Message)
}
// The ranked candidate rides on the param as a machine-readable suggestion
// so an agent can retry without parsing prose.
if len(verr.Params) == 1 {
found := false
for _, s := range verr.Params[0].Suggestions {
if s == "--range" {
found = true
}
}
if !found {
t.Errorf("Params[0].Suggestions should include --range, got %v", verr.Params[0].Suggestions)
}
}
// The same candidate is also carried in the human-facing hint.
if !strings.Contains(verr.Hint, "--range") {
t.Errorf("hint should suggest --range, got %q", verr.Hint)
}
}
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
c := &cobra.Command{Use: "demo"}
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if exitErr.Detail.Type != "flag_error" {
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
// Non-unknown-flag errors stay generic: invalid_argument subtype, no
// structured param, generic --help hint (no "did you mean" suggestion).
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument (non-unknown-flag errors stay generic)", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if verr.Param != "" || len(verr.Params) != 0 {
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
}
if strings.Contains(verr.Hint, "did you mean") {
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
}
}

View File

@@ -9,10 +9,12 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -102,7 +104,7 @@ func findLeaf(t *testing.T, parent *cobra.Command, names ...string) *cobra.Comma
}
// Happy path: a valid policy.yml denies one specific command. The denied
// command's RunE returns a typed ExitError envelope; allowed commands are
// command's RunE returns a typed error envelope; allowed commands are
// untouched.
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
cfgDir := tmpHome(t)
@@ -127,13 +129,27 @@ max_risk: write
if err == nil {
t.Fatalf("+delete-doc RunE should return an error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "command_denied" {
t.Fatalf("expected command_denied ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok || detail["reason_code"] != "command_denylisted" {
t.Errorf("reason_code = %v, want command_denylisted", detail["reason_code"])
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// The denial taxonomy (reason_code, layer, rule) is preserved on the
// wrapped *platform.CommandDeniedError cause and folded into the hint.
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) {
t.Fatalf("error chain should expose *platform.CommandDeniedError")
}
if cd.ReasonCode != "command_denylisted" {
t.Errorf("CommandDeniedError.ReasonCode = %q, want command_denylisted", cd.ReasonCode)
}
if !strings.Contains(verr.Hint, "command_denylisted") {
t.Errorf("hint should surface reason_code command_denylisted, got %q", verr.Hint)
}
// im/+send must be denied (domain not in Allow).

View File

@@ -8,9 +8,9 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
internalplatform "github.com/larksuite/cli/internal/platform"
)
@@ -34,16 +34,8 @@ import (
// lands directly on their RunE, which now carries the guard.
//
// makeErr is called for every guarded dispatch; it must return a fresh
// *output.ExitError each time (the envelope writer mutates a few fields
// as it serialises).
// Deprecated: installFatalGuard accepts a *output.ExitError-producing lambda,
// which is part of the legacy error surface that predates the typed error
// contract introduced by errs/. New code MUST NOT add new callers — the
// platform-extension fatal-guard plumbing will switch to typed errs.* errors
// when the platform-extension framework migrates. This wrapper is retained
// only for the existing in-tree call sites; it will be removed once they
// have moved to the typed surface.
func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) {
// typed error each time.
func installFatalGuard(rootCmd *cobra.Command, makeErr func() error) {
// Two cobra subcommands are injected lazily at Execute() time and
// would otherwise slip past walkGuard. We pre-register both so
// walkGuard catches them.
@@ -80,120 +72,65 @@ func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError)
}
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
// failure as a structured plugin_install envelope before any command
// runs.
// Deprecated: installPluginInstallErrorGuard produces a legacy
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
// such producers — plugin install failures should surface as a typed
// *errs.XxxError once the platform-extension framework migrates. This
// helper is retained only while existing call sites are migrated; it will
// be removed once they have moved to the typed surface.
// failure as a typed validation error (failed_precondition) before any
// command runs.
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
makeErr := func() *output.ExitError {
makeErr := func() error {
var pi *internalplatform.PluginInstallError
if errors.As(installErr, &pi) {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_install",
Message: pi.Error(),
Detail: map[string]any{
"plugin": pi.PluginName,
"reason_code": pi.ReasonCode,
"reason": pi.Reason,
},
},
Err: installErr,
}
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_install",
Message: installErr.Error(),
Detail: map[string]any{
"reason_code": internalplatform.ReasonInstallFailed,
},
},
Err: installErr,
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", pi.Error()).
WithHint("plugin %q failed to install (reason_code %s); fix or remove the plugin before running commands", pi.PluginName, pi.ReasonCode).
WithCause(installErr)
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", installErr.Error()).
WithHint("a plugin failed to install (reason_code %s); fix or remove the plugin before running commands", internalplatform.ReasonInstallFailed).
WithCause(installErr)
}
installFatalGuard(rootCmd, makeErr)
}
// installPluginConflictGuard surfaces a Plugin.Restrict() configuration
// error (single plugin invalid Rule or multiple plugins each contributing
// Restrict). The design separates the envelope type:
// Restrict). The hint separates the two failure modes by reason code:
//
// - "plugin_install" with reason_code "invalid_rule" - single bad rule
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
// - "invalid_rule" - single bad rule
// - "multiple_restrict_plugins" - multiple Restrict plugins conflict
//
// Either way the CLI must NOT silently continue with a broken policy.
// Deprecated: installPluginConflictGuard produces a legacy *output.ExitError
// via its internal makeErr lambda. New code MUST NOT add such producers —
// plugin conflict failures should surface as a typed *errs.XxxError once the
// platform-extension framework migrates. This helper is retained only while
// existing call sites are migrated; it will be removed once they have moved
// to the typed surface.
func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
makeErr := func() *output.ExitError {
envelopeType := "plugin_install"
makeErr := func() error {
reasonCode := internalplatform.ReasonInvalidRule
if errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
envelopeType = "plugin_conflict"
reasonCode = internalplatform.ReasonMultipleRestricts
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: envelopeType,
Message: err.Error(),
Detail: map[string]any{
"reason_code": reasonCode,
},
},
Err: err,
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
WithHint("plugin policy configuration is broken (reason_code %s); fix the plugin's Restrict rule or remove the conflicting plugin", reasonCode).
WithCause(err)
}
installFatalGuard(rootCmd, makeErr)
}
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
// failure as a plugin_lifecycle envelope. The reason_code splits
// returned-error vs panic so consumers (audit / on-call) can tell the
// two failure modes apart.
// Deprecated: installPluginLifecycleErrorGuard produces a legacy
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
// such producers — plugin lifecycle failures should surface as a typed
// *errs.XxxError once the platform-extension framework migrates. This
// helper is retained only while existing call sites are migrated; it will
// be removed once they have moved to the typed surface.
// failure as a typed validation error (failed_precondition). The hint's
// reason code splits returned-error vs panic so consumers (audit /
// on-call) can tell the two failure modes apart.
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
makeErr := func() *output.ExitError {
makeErr := func() error {
reasonCode := "lifecycle_failed"
detail := map[string]any{
"reason_code": reasonCode,
}
hookName := ""
var le *hook.LifecycleError
if errors.As(err, &le) {
if le.Panic {
reasonCode = "lifecycle_panic"
}
detail = map[string]any{
"reason_code": reasonCode,
"hook_name": le.HookName,
"event": "startup",
}
hookName = le.HookName
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_lifecycle",
Message: err.Error(),
Detail: detail,
},
Err: err,
typed := errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
WithCause(err)
if hookName != "" {
return typed.WithHint("plugin startup hook %q failed (reason_code %s); fix or remove the plugin before running commands", hookName, reasonCode)
}
return typed.WithHint("a plugin startup hook failed (reason_code %s); fix or remove the plugin before running commands", reasonCode)
}
installFatalGuard(rootCmd, makeErr)
}
@@ -219,14 +156,7 @@ func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
//
// This way the very first non-nil step in cobra's chain is always our
// guard, regardless of which leaf the user invoked.
// Deprecated: walkGuard accepts a *output.ExitError-producing lambda, part
// of the legacy error surface that predates the typed error contract
// introduced by errs/. New code MUST NOT add new callers — the platform-
// extension guard plumbing will switch to typed errs.* errors when the
// platform-extension framework migrates. This wrapper is retained only for
// the existing in-tree call sites; it will be removed once they have moved
// to the typed surface.
func walkGuard(cmd *cobra.Command, makeErr func() *output.ExitError) {
func walkGuard(cmd *cobra.Command, makeErr func() error) {
if cmd == nil {
return
}

View File

@@ -6,12 +6,14 @@ package cmd
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
@@ -32,7 +34,7 @@ func (failClosedAbortingPlugin) Install(platform.Registrar) error {
}
// When a FailClosed plugin fails to install, buildInternal must
// install a PersistentPreRunE that returns a structured *output.ExitError.
// install a PersistentPreRunE that returns a typed *errs.ValidationError.
// The user must NEVER see a silent partial-install state.
//
// This pins the build.go fix for codex's NEW ISSUE about
@@ -93,26 +95,31 @@ func TestBuildInternal_failClosedAbortsCLI(t *testing.T) {
checkGuardError(t, leaf.RunE(leaf, nil))
}
// checkGuardError asserts that err is the structured plugin_install
// ExitError the guard produces.
// checkGuardError asserts that err is the typed validation error the
// install guard produces: a failed_precondition *errs.ValidationError
// (exit 2) whose message + hint preserve the plugin name and the
// install_failed reason code (the recovery info that lived in the legacy
// detail map).
func checkGuardError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatalf("PersistentPreRunE must surface the install error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_install" {
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
detail := exitErr.Detail.Detail.(map[string]any)
if detail["plugin"] != "policy" {
t.Errorf("detail.plugin = %v, want policy", detail["plugin"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if detail["reason_code"] != internalplatform.ReasonInstallFailed {
t.Errorf("detail.reason_code = %v, want install_failed", detail["reason_code"])
if !strings.Contains(verr.Hint, "policy") {
t.Errorf("hint should name the failing plugin %q, got %q", "policy", verr.Hint)
}
if !strings.Contains(verr.Hint, internalplatform.ReasonInstallFailed) {
t.Errorf("hint should surface reason_code %q, got %q", internalplatform.ReasonInstallFailed, verr.Hint)
}
}

View File

@@ -8,11 +8,13 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -156,19 +158,23 @@ func TestPluginPipeline_wrapAbortReachesEnvelope(t *testing.T) {
}
err = leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
detail := exitErr.Detail.Detail.(map[string]any)
if detail["reason_code"] != "aborted" {
t.Errorf("detail.reason_code = %v, want aborted", detail["reason_code"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if detail["hook_name"] != "policy-plugin.policy" {
t.Errorf("detail.hook_name = %v, want policy-plugin.policy", detail["hook_name"])
// The namespaced hook name and the abort semantics are preserved in the
// message so a caller can identify which plugin hook rejected the call.
if !strings.Contains(verr.Message, "policy-plugin.policy") {
t.Errorf("message should name the aborting hook policy-plugin.policy, got %q", verr.Message)
}
if !strings.Contains(verr.Message, "aborted") {
t.Errorf("message should describe the abort, got %q", verr.Message)
}
// errors.As must still reach the original AbortError so consumers
@@ -409,15 +415,20 @@ func TestPluginConflictGuard_MultipleRestrictAbortsCLI(t *testing.T) {
t.Fatalf("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_conflict" {
t.Errorf("envelope type = %q, want plugin_conflict", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "multiple_restrict_plugins" {
t.Errorf("reason_code = %v, want multiple_restrict_plugins", rc)
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// reason_code multiple_restrict_plugins is folded into the hint so the
// operator can distinguish a multi-Restrict conflict from a bad rule.
if !strings.Contains(verr.Hint, "multiple_restrict_plugins") {
t.Errorf("hint should surface reason_code multiple_restrict_plugins, got %q", verr.Hint)
}
}
@@ -447,15 +458,20 @@ func TestPluginConflictGuard_InvalidRuleAbortsCLI(t *testing.T) {
t.Fatalf("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_install" {
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "invalid_rule" {
t.Errorf("reason_code = %v, want invalid_rule", rc)
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// reason_code invalid_rule is folded into the hint, distinct from the
// multiple_restrict_plugins conflict path.
if !strings.Contains(verr.Hint, "invalid_rule") {
t.Errorf("hint should surface reason_code invalid_rule, got %q", verr.Hint)
}
}
@@ -484,19 +500,24 @@ func TestPluginLifecycleGuard_StartupErrorAbortsCLI(t *testing.T) {
leaf := findRunnableLeaf(root)
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_lifecycle" {
t.Errorf("envelope type = %q, want plugin_lifecycle", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "lifecycle_failed" {
t.Errorf("reason_code = %v, want lifecycle_failed", d["reason_code"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if d["hook_name"] != "lc.start" {
t.Errorf("hook_name = %v, want lc.start", d["hook_name"])
// reason_code lifecycle_failed (vs lifecycle_panic) and the failing
// hook name are folded into the hint so audit / on-call can tell the
// failure mode and which hook failed.
if !strings.Contains(verr.Hint, "lifecycle_failed") {
t.Errorf("hint should surface reason_code lifecycle_failed, got %q", verr.Hint)
}
if !strings.Contains(verr.Hint, "lc.start") {
t.Errorf("hint should name the failing hook lc.start, got %q", verr.Hint)
}
}
@@ -520,12 +541,20 @@ func TestPluginLifecycleGuard_StartupPanicAbortsCLI(t *testing.T) {
}
leaf := findRunnableLeaf(root)
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "lifecycle_panic" {
t.Errorf("reason_code = %v, want lifecycle_panic", rc)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
// A panicking startup hook is distinguished from a returned error by
// reason_code lifecycle_panic in the hint.
if !strings.Contains(verr.Hint, "lifecycle_panic") {
t.Errorf("hint should surface reason_code lifecycle_panic, got %q", verr.Hint)
}
}
@@ -579,19 +608,24 @@ func TestWrapperPanic_BecomesHookPanicEnvelope(t *testing.T) {
}()
err = leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "panic" {
t.Errorf("reason_code = %v, want panic", d["reason_code"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if d["hook_name"] != "p.boom" {
t.Errorf("hook_name = %v, want p.boom (namespaced)", d["hook_name"])
// The recovered panic surfaces as a structured error naming the
// namespaced hook (p.boom) and describing the panic, so the process
// never crashes and the caller can attribute the failure.
if !strings.Contains(verr.Message, "p.boom") {
t.Errorf("message should name the namespaced hook p.boom, got %q", verr.Message)
}
if !strings.Contains(verr.Message, "panic") {
t.Errorf("message should describe the panic, got %q", verr.Message)
}
}
@@ -653,19 +687,24 @@ func TestWrapperFactoryPanic_BecomesHookPanicEnvelope(t *testing.T) {
}()
err = leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "panic" {
t.Errorf("reason_code = %v, want panic", d["reason_code"])
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if d["hook_name"] != "fac.bad-factory" {
t.Errorf("hook_name = %v, want fac.bad-factory (namespaced)", d["hook_name"])
// A panic in the wrapper FACTORY (not just the inner handler) is
// recovered into the same structured panic error, naming the
// namespaced hook fac.bad-factory.
if !strings.Contains(verr.Message, "fac.bad-factory") {
t.Errorf("message should name the namespaced hook fac.bad-factory, got %q", verr.Message)
}
if !strings.Contains(verr.Message, "panic") {
t.Errorf("message should describe the panic, got %q", verr.Message)
}
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
@@ -53,7 +54,9 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool, brand, lang string, useAfter bool) error {
if err := core.ValidateProfileName(name); err != nil {
return output.ErrValidation("%v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).
WithCause(err).
WithParam("--name")
}
langPref, err := cmdutil.ParseLangFlag(lang)
@@ -64,46 +67,57 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
// Read secret from stdin
if !appSecretStdin {
return output.ErrValidation("app secret must be provided via stdin: use --app-secret-stdin and pipe the secret")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret must be provided via stdin").
WithHint("use --app-secret-stdin and pipe the secret").
WithParam("--app-secret-stdin")
}
scanner := bufio.NewScanner(f.IOStreams.In)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return output.ErrValidation("failed to read secret from stdin: %v", err)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "failed to read secret from stdin: %v", err).
WithCause(err).
WithParam("--app-secret-stdin")
}
return output.ErrValidation("stdin is empty, expected app secret")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "stdin is empty, expected app secret").
WithHint("pipe the app secret to stdin").
WithParam("--app-secret-stdin")
}
appSecret := strings.TrimSpace(scanner.Text())
if appSecret == "" {
return output.ErrValidation("app secret read from stdin is empty")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret read from stdin is empty").
WithHint("pipe a non-empty app secret to stdin").
WithParam("--app-secret-stdin")
}
// Load or create config
multi, err := core.LoadMultiAppConfig()
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return output.Errorf(output.ExitInternal, "internal", "failed to load config: %v", err)
return errs.NewInternalError(errs.SubtypeFileIO, "failed to load config: %v", err).WithCause(err)
}
multi = &core.MultiAppConfig{}
}
// Check name uniqueness
if multi.FindApp(name) != nil {
return output.ErrValidation("profile %q already exists", name)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", name).
WithHint("choose a different name, or remove the existing profile first").
WithParam("--name")
}
// Check app-id uniqueness — keychain stores secrets by appId, so
// multiple profiles sharing the same appId would collide on credentials.
for _, a := range multi.Apps {
if a.AppId == appID {
return output.ErrValidation("app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName())
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "app-id %q is already used by profile %q; each profile must have a unique app-id", appID, a.ProfileName()).
WithParam("--app-id")
}
}
// Store secret securely
secret, err := core.ForStorage(appID, core.PlainSecret(appSecret), f.Keychain)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
return errs.NewInternalError(errs.SubtypeStorage, "%v", err).WithCause(err)
}
parsedBrand := core.ParseBrand(brand)
@@ -134,7 +148,7 @@ func profileAddRun(f *cmdutil.Factory, name, appID string, appSecretStdin bool,
}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile %q added (%s, %s)", name, appID, parsedBrand))

View File

@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -45,7 +46,7 @@ func profileListRun(f *cmdutil.Factory) error {
output.PrintJson(f.IOStreams.Out, []profileListItem{})
return nil
}
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "failed to load config: %v", err).WithCause(err)
}
if multi == nil || len(multi.Apps) == 0 {
output.PrintJson(f.IOStreams.Out, []profileListItem{})

View File

@@ -11,6 +11,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
@@ -50,6 +51,16 @@ func TestProfileAddRun_InvalidExistingConfigReturnsError(t *testing.T) {
if !strings.Contains(err.Error(), "failed to load config") {
t.Fatalf("error = %v, want failed to load config", err)
}
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("error type = %T, want *errs.InternalError; err=%v", err, err)
}
if internalErr.Subtype != errs.SubtypeFileIO {
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
}
if code := output.ExitCodeOf(err); code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal)
}
}
// TestProfileAddRun_Lang covers the unified --lang contract on profile add:
@@ -95,9 +106,9 @@ func TestProfileAddRun_Lang(t *testing.T) {
if err == nil {
t.Fatal("expected validation error for --lang ZH, got nil")
}
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Code != output.ExitValidation {
t.Fatalf("expected ExitValidation, got %T: %v", err, err)
var valErr *errs.ValidationError
if !errors.As(err, &valErr) || output.ExitCodeOf(err) != output.ExitValidation {
t.Fatalf("expected typed validation error with ExitValidation, got %T: %v", err, err)
}
})
}
@@ -406,17 +417,226 @@ func TestProfileUseRun_SaveFailureReturnsStructuredError(t *testing.T) {
func assertInternalExitError(t *testing.T, err error, wantMsg string) {
t.Helper()
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; err=%v", err, err)
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("error type = %T, want *errs.InternalError; err=%v", err, err)
}
if exitErr.Code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
if internalErr.Subtype != errs.SubtypeStorage {
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeStorage)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "internal" {
t.Fatalf("detail = %#v, want internal detail", exitErr.Detail)
if internalErr.Cause == nil {
t.Fatalf("cause = nil, want wrapped underlying error")
}
if !strings.Contains(exitErr.Detail.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", exitErr.Detail.Message, wantMsg)
if !strings.Contains(internalErr.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", internalErr.Message, wantMsg)
}
if code := output.ExitCodeOf(err); code != output.ExitInternal {
t.Fatalf("exit code = %d, want %d (ExitInternal)", code, output.ExitInternal)
}
}
// assertValidationError asserts err is a typed *errs.ValidationError with the
// given subtype, message fragment, and exit code 2.
func assertValidationError(t *testing.T, err error, wantSubtype errs.Subtype, wantMsg string) *errs.ValidationError {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("error type = %T, want *errs.ValidationError; err=%v", err, err)
}
if valErr.Subtype != wantSubtype {
t.Fatalf("subtype = %q, want %q", valErr.Subtype, wantSubtype)
}
if !strings.Contains(valErr.Message, wantMsg) {
t.Fatalf("message = %q, want contains %q", valErr.Message, wantMsg)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
return valErr
}
func saveTwoProfiles(t *testing.T) {
t.Helper()
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{Name: "default", AppId: "app-default", AppSecret: core.PlainSecret("secret-default"), Brand: core.BrandFeishu},
{Name: "target", AppId: "app-target", AppSecret: core.PlainSecret("secret-target"), Brand: core.BrandLark},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}
func TestProfileAddRun_ValidationErrors(t *testing.T) {
t.Run("invalid profile name", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "bad name!", "app-x", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "")
if valErr.Param != "--name" {
t.Fatalf("param = %q, want %q", valErr.Param, "--name")
}
if valErr.Cause == nil {
t.Fatal("cause = nil, want wrapped validation error")
}
})
t.Run("missing app-secret-stdin flag", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileAddRun(f, "p", "app-x", false, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "app secret must be provided via stdin")
if valErr.Param != "--app-secret-stdin" {
t.Fatalf("param = %q, want %q", valErr.Param, "--app-secret-stdin")
}
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
t.Run("empty stdin", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("")
err := profileAddRun(f, "p", "app-x", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "stdin is empty")
if valErr.Param != "--app-secret-stdin" {
t.Fatalf("param = %q, want %q", valErr.Param, "--app-secret-stdin")
}
})
t.Run("blank secret on stdin", func(t *testing.T) {
setupProfileConfigDir(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader(" \n")
err := profileAddRun(f, "p", "app-x", true, "feishu", "", false)
assertValidationError(t, err, errs.SubtypeInvalidArgument, "app secret read from stdin is empty")
})
t.Run("duplicate profile name", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "default", "app-new", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, `profile "default" already exists`)
if valErr.Param != "--name" {
t.Fatalf("param = %q, want %q", valErr.Param, "--name")
}
})
t.Run("duplicate app-id", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret\n")
err := profileAddRun(f, "fresh", "app-default", true, "feishu", "", false)
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "already used by profile")
if valErr.Param != "--app-id" {
t.Fatalf("param = %q, want %q", valErr.Param, "--app-id")
}
})
}
func TestProfileUseRun_ValidationErrors(t *testing.T) {
t.Run("no previous profile for toggle", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileUseRun(f, "-")
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "no previous profile to switch back to")
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
t.Run("profile not found", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileUseRun(f, "ghost")
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
})
}
func TestProfileRenameRun_ValidationErrors(t *testing.T) {
t.Run("invalid new name", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRenameRun(f, "default", "bad name!")
valErr := assertValidationError(t, err, errs.SubtypeInvalidArgument, "")
if valErr.Cause == nil {
t.Fatal("cause = nil, want wrapped validation error")
}
})
t.Run("old profile not found", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRenameRun(f, "ghost", "fresh")
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
})
t.Run("new name already exists", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRenameRun(f, "default", "target")
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, `profile "target" already exists`)
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
}
func TestProfileRemoveRun_ValidationErrors(t *testing.T) {
t.Run("profile not found", func(t *testing.T) {
setupProfileConfigDir(t)
saveTwoProfiles(t)
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRemoveRun(f, "ghost")
assertValidationError(t, err, errs.SubtypeInvalidArgument, `profile "ghost" not found`)
})
t.Run("cannot remove the only profile", func(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
CurrentApp: "solo",
Apps: []core.AppConfig{
{Name: "solo", AppId: "app-solo", AppSecret: core.PlainSecret("secret-solo"), Brand: core.BrandFeishu},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileRemoveRun(f, "solo")
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "cannot remove the only profile")
if valErr.Hint == "" {
t.Fatal("hint is empty, want actionable hint")
}
})
}
func TestProfileListRun_InvalidConfigReturnsValidationError(t *testing.T) {
dir := setupProfileConfigDir(t)
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid json"), 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := profileListRun(f)
valErr := assertValidationError(t, err, errs.SubtypeFailedPrecondition, "failed to load config")
if valErr.Cause == nil {
t.Fatal("cause = nil, want wrapped load error")
}
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -40,11 +41,12 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
idx := multi.FindAppIndex(name)
if idx < 0 {
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
}
if len(multi.Apps) == 1 {
return output.ErrValidation("cannot remove the only profile")
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot remove the only profile").
WithHint("add another profile first: lark-cli profile add")
}
app := &multi.Apps[idx]
@@ -65,7 +67,7 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
// Best-effort credential cleanup after config commit

View File

@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
@@ -30,7 +31,7 @@ func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
if err := core.ValidateProfileName(newName); err != nil {
return output.ErrValidation("%v", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
}
multi, err := core.LoadOrNotConfigured()
@@ -40,7 +41,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
idx := multi.FindAppIndex(oldName)
if idx < 0 {
return output.ErrValidation("profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", oldName, strings.Join(multi.ProfileNames(), ", "))
}
// Check new name uniqueness across other profiles, allowing renames to this
@@ -50,7 +51,8 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
continue
}
if multi.Apps[i].Name == newName || multi.Apps[i].AppId == newName {
return output.ErrValidation("profile %q already exists", newName)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "profile %q already exists", newName).
WithHint("choose a different name")
}
}
@@ -66,7 +68,7 @@ func profileRenameRun(f *cmdutil.Factory, oldName, newName string) error {
}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Profile renamed: %q -> %q", oldProfileName, newName))

View File

@@ -9,6 +9,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
@@ -40,14 +41,15 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
// Handle "-" for toggle-back
if name == "-" {
if multi.PreviousApp == "" {
return output.ErrValidation("no previous profile to switch back to")
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "no previous profile to switch back to").
WithHint("switch to a profile by name first: lark-cli profile use <name>")
}
name = multi.PreviousApp
}
app := multi.FindApp(name)
if app == nil {
return output.ErrValidation("profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "profile %q not found, available profiles: %s", name, strings.Join(multi.ProfileNames(), ", "))
}
targetName := app.ProfileName()
@@ -66,7 +68,7 @@ func profileUseRun(f *cmdutil.Factory, name string) error {
multi.CurrentApp = targetName
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Switched to profile %q (%s, %s)", targetName, app.AppId, app.Brand))

View File

@@ -9,10 +9,10 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// pruneForStrictMode removes commands incompatible with the active strict mode.
@@ -65,10 +65,10 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
// pick auth's instead of our denial. A leaf-level no-op makes
// cobra stop here and proceed to the wrapped RunE.
//
// strict-mode keeps its short Message + independent Hint and
// composes the shared detail.* / wrapped-CommandDeniedError shape
// by hand; BuildDenialError would override Message with the
// CommandDeniedError.Error() long form.
// strict-mode keeps its short Message + independent Hint and wraps
// the CommandDeniedError as the Cause by hand; BuildDenialError
// would override Message with the CommandDeniedError.Error() long
// form.
stubMessage := fmt.Sprintf(
"strict mode is %q, only %s-identity commands are available",
mode, mode.ForcedIdentity())
@@ -105,20 +105,9 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
},
RunE: func(c *cobra.Command, _ []string) error {
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
// Legacy *output.ExitError producer: this literal predates the
// typed error contract introduced by errs/. New denial sites MUST
// NOT construct *output.ExitError directly — they should return a
// typed *errs.XxxError once the cmdpolicy framework migrates.
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "command_denied",
Message: stubMessage,
Hint: stubHint,
Detail: cmdpolicy.DenialDetailMap(cd),
},
Err: cd,
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint).
WithCause(cd)
},
}
}

View File

@@ -8,6 +8,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
@@ -247,9 +248,12 @@ func TestStrictModeStub_BypassesArgsValidator(t *testing.T) {
}
}
// Pins the strict-mode envelope shape: structured detail.* / wrapped
// CommandDeniedError for external agents, AND the historical short
// Message + independent Hint for existing consumers.
// Pins the strict-mode typed envelope: a failed_precondition
// *errs.ValidationError (exit 2) carrying the short historical Message,
// a Hint that still surfaces the policy layer + reason code (the
// safety-critical recovery info that lived in the legacy detail map),
// and the wrapped *platform.CommandDeniedError so external agents can
// still inspect the structured denial taxonomy via errors.As.
func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
@@ -262,30 +266,33 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
t.Fatalf("strict-mode stub RunE should return error")
}
var ee *output.ExitError
if !errors.As(err, &ee) {
t.Fatalf("err is not *output.ExitError: %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("err is not *errs.ValidationError: %T", err)
}
if ee.Detail == nil {
t.Fatalf("ExitError.Detail is nil; envelope writer cannot emit JSON")
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if ee.Detail.Type != "command_denied" {
t.Errorf("Detail.Type = %q, want command_denied", ee.Detail.Type)
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
dm, ok := ee.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("Detail.Detail = %T, want map[string]any", ee.Detail.Detail)
// Short historical Message is preserved verbatim.
if verr.Message != `strict mode is "bot", only bot-identity commands are available` {
t.Errorf("Message = %q, want short historical form", verr.Message)
}
if got, _ := dm["layer"].(string); got != cmdpolicy.LayerStrictMode {
t.Errorf("Detail.Detail[layer] = %q, want %q", got, cmdpolicy.LayerStrictMode)
// The denial layer + reason code remain user-readable in the hint, and
// the historical switch-policy guidance is still appended.
if !strings.Contains(verr.Hint, cmdpolicy.LayerStrictMode) {
t.Errorf("Hint = %q, want substring %q (policy layer)", verr.Hint, cmdpolicy.LayerStrictMode)
}
if got, _ := dm["reason_code"].(string); got != "identity_not_supported" {
t.Errorf("Detail.Detail[reason_code] = %q, want identity_not_supported", got)
if !strings.Contains(verr.Hint, "identity_not_supported") {
t.Errorf("Hint = %q, want substring identity_not_supported (reason code)", verr.Hint)
}
if got, _ := dm["policy_source"].(string); got != "strict-mode" {
t.Errorf("Detail.Detail[policy_source] = %q, want strict-mode", got)
if !strings.Contains(verr.Hint, "if the user explicitly wants to switch policy") {
t.Errorf("Hint = %q, want historical switch-policy guidance", verr.Hint)
}
// The structured denial taxonomy survives on the wrapped cause.
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) {
t.Fatalf("err does not unwrap to *platform.CommandDeniedError")
@@ -296,15 +303,12 @@ func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
if cd.ReasonCode != "identity_not_supported" {
t.Errorf("CommandDeniedError.ReasonCode = %q, want identity_not_supported", cd.ReasonCode)
}
if cd.PolicySource != "strict-mode" {
t.Errorf("CommandDeniedError.PolicySource = %q, want strict-mode", cd.PolicySource)
}
if !strings.Contains(cd.Reason, `strict mode is "bot"`) {
t.Errorf("CommandDeniedError.Reason = %q, want substring 'strict mode is \"bot\"'", cd.Reason)
}
if ee.Detail.Message != `strict mode is "bot", only bot-identity commands are available` {
t.Errorf("Detail.Message = %q, want short historical form", ee.Detail.Message)
}
if !strings.HasPrefix(ee.Detail.Hint, "if the user explicitly wants to switch policy") {
t.Errorf("Detail.Hint = %q, want historical hint", ee.Detail.Hint)
}
}
// strictModeStubFrom must write the denial annotations so the hook

View File

@@ -13,17 +13,12 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/errcompat"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update"
@@ -217,56 +212,37 @@ func configureFlagCompletions(args []string) {
// and returns the process exit code.
//
// Dispatch order:
// 1. Legacy shapes (*core.ConfigError, *internalauth.NeedAuthorizationError)
// are promoted via errcompat to their typed errs/ counterparts, with the
// original preserved in the Cause chain.
// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
// *errs.SecurityPolicyError, *errs.AuthenticationError): render via the
// typed envelope writer, which lifts extension fields (missing_scopes,
// console_url, challenge_url, ...) to the top level. Routed by
// errs.CategoryOf via ExitCodeOf.
// 3. Legacy *output.ExitError: asExitError adapts it to the legacy
// envelope, written via WriteErrorEnvelope.
// 4. Cobra errors (required flags, unknown commands, etc.): plain text.
// 1. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
// *errs.SecurityPolicyError, *errs.AuthenticationError, *errs.ConfigError):
// render via the typed envelope writer, which lifts extension fields
// (missing_scopes, console_url, challenge_url, ...) to the top level.
// Routed by errs.CategoryOf via ExitCodeOf. Auth and config errors are
// constructed typed at their origin (internal/auth, internal/core), so the
// dispatcher no longer promotes any legacy shape here.
// 2. PartialFailure / BareError signals: the result envelope is already on
// stdout; honor the exit code and write nothing to stderr.
// 3. Residual cobra usage errors (missing required flag, unknown command,
// argument validation): typed as an invalid_argument envelope (exit 2),
// matching the explicit flag/subcommand guards. Flag parse errors are
// already typed upstream by the root FlagErrorFunc.
func handleRootError(f *cmdutil.Factory, err error) int {
errOut := f.IOStreams.ErrOut
// Promote legacy error shapes into typed errs/ before envelope marshal.
// NeedAuthorizationError check is first because it is the more specific
// shape; *core.ConfigError check follows. errors.As preserves the original
// in the Cause chain, so external errors.As(&core.ConfigError{}) consumers
// (cmd/auth/list.go, cmd/doctor/doctor.go, ...) still match.
//
// Outer-typed short-circuit: if err is already a typed *errs.* error,
// skip PromoteXxxError so the producer's Subtype / Hint / extension
// fields are not overwritten by a coarser promoted shape derived from a
// legacy error buried in its Cause chain. Promotion is only for legacy
// untyped entry points.
if !isOuterTypedError(err) {
var needAuthErr *internalauth.NeedAuthorizationError
if errors.As(err, &needAuthErr) {
err = errcompat.PromoteAuthError(needAuthErr)
} else {
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
err = errcompat.PromoteConfigError(cfgErr)
}
}
}
// When the typed error is a need_user_authorization signal, fold in the
// current command's declared scopes as a Hint so the user/AI sees the
// concrete scope(s) to re-auth with. The hint is computed on the fly from
// local shortcut/service metadata — it never depends on server state.
applyNeedAuthorizationHint(f, err)
if !errs.IsRaw(err) {
applyNeedAuthorizationHint(f, err)
}
// Staged dispatch: capture the typed exit code BEFORE attempting the
// envelope write. WriteTypedErrorEnvelope is best-effort on the wire
// (partial-write still returns true) so the exit code we read here is
// preserved even if stderr is torn — torn stderr must not downgrade
// typed exits 3/4/6/10 to the legacy "Error:" path with exit 1.
// typed exits 3/4/6/10 to the plain "Error:" path with exit 1.
// WriteTypedErrorEnvelope still returns false when err carries no
// Problem; in that case we fall through to the legacy bridge below.
// Problem; in that case we fall through to the signal / plain-text paths.
typedExit := output.ExitCodeOf(err)
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
return typedExit
@@ -279,58 +255,63 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return pfErr.Code
}
if exitErr := asExitError(err); exitErr != nil {
if !exitErr.Raw {
// Raw errors (e.g. from `api` command via output.MarkRaw)
// preserve the original API error detail; skip enrichment
// which would clear it.
enrichMissingScopeError(f, exitErr)
enrichPermissionError(f, exitErr)
// Silent-exit signal (e.g. `auth check` predicate, or `update --json`):
// stdout already carries the result; honor the requested exit code and
// write nothing to stderr.
var bareErr *output.BareError
if errors.As(err, &bareErr) {
return bareErr.Code
}
// Errors reaching here are untyped: every RunE returns a typed errs.* error
// and flag-parse errors are typed by the root FlagErrorFunc. The remainder
// is either a cobra usage mistake (missing required flag, unknown command,
// wrong arg count), which cobra surfaces as a plain error identified by its
// stable text — the same external contract unknownFlagName relies on — or an
// untyped error that leaked past the typed boundary. Classify the former as
// invalid_argument (exit 2, like the explicit guards); treat the latter as an
// internal fault (exit 5) rather than blaming the user's input. The message
// is preserved either way, and the typed envelope still carries any pending
// deprecation notice.
var fallback error
if isCobraUsageError(err) {
fallback = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error())
} else {
fallback = errs.NewInternalError(errs.SubtypeUnknown, "%s", err.Error()).WithCause(err)
}
output.WriteTypedErrorEnvelope(errOut, fallback, string(f.ResolvedIdentity))
return output.ExitCodeOf(fallback)
}
// cobraUsageErrorMarkers are the stable error-text fragments cobra / pflag
// (pinned at v1.10.2) emit for usage mistakes — missing required flag, unknown
// command / flag, wrong argument count. Cobra surfaces these as plain errors,
// not a typed value we can match on, so the dispatcher recognizes them by text;
// this is the same external contract unknownFlagName already depends on. A
// residual error matching none of these has leaked the typed boundary and is
// treated as an internal fault, not a user error.
var cobraUsageErrorMarkers = []string{
"unknown command ",
"unknown flag: ",
"unknown shorthand",
"required flag(s) ",
"flag needs an argument",
"bad flag syntax:",
"no such flag ",
"invalid argument ",
"arg(s), ", // accepts / requires N arg(s), received / only received M
}
// isCobraUsageError reports whether err is a cobra / pflag usage mistake,
// identified by the stable error text of the pinned cobra version.
func isCobraUsageError(err error) bool {
msg := err.Error()
for _, m := range cobraUsageErrorMarkers {
if strings.Contains(msg, m) {
return true
}
output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity))
return exitErr.Code
}
// A backward-compat alias records its deprecation notice in PreRunE, which
// runs before cobra's required-flag validation — but a missing required flag
// fails before RunE and lands here, where the bare "Error:" line would drop
// the notice. When a deprecation is pending, route through the structured
// envelope so the migration hint still reaches the caller; all other errors
// keep the existing plain output.
if deprecation.GetPending() != nil {
output.WriteErrorEnvelope(errOut, &output.ExitError{
Code: 1,
Detail: &output.ErrDetail{Type: "validation", Message: err.Error()},
}, string(f.ResolvedIdentity))
return 1
}
fmt.Fprintln(errOut, "Error:", err)
return 1
}
// isOuterTypedError returns true if err is a typed *errs.* error AT THE
// TOP OF THE CHAIN (not buried inside Unwrap). Used by handleRootError
// to gate PromoteXxxError so a producer's outer typed envelope is never
// overwritten by a coarser shape derived from its legacy Cause.
func isOuterTypedError(err error) bool {
_, ok := err.(errs.TypedError)
return ok
}
// asExitError converts known structured error types to *output.ExitError.
// Returns nil for unrecognized errors (e.g. cobra flag errors).
//
// Deprecated: legacy *output.ExitError bridge.
func asExitError(err error) *output.ExitError {
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint)
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return exitErr
}
return nil
return false
}
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
@@ -361,13 +342,10 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
}
}
// Deprecated: unknownSubcommandRunE produces a legacy *output.ExitError that
// predates the typed error contract introduced by errs/. New code MUST NOT
// add producers of this shape — unknown-subcommand signals should move to
// a typed *errs.ValidationError (or a dedicated typed error) carrying the
// agent-protocol metadata as typed extension fields. This helper is retained
// only while existing dispatch sites are migrated; it will be removed once
// they have moved to the typed surface.
// unknownSubcommandRunE replaces cobra's silent help fallback on group commands
// with a typed *errs.ValidationError: a flag that belongs to a missing
// subcommand, a misplaced subcommand-only flag, or an unknown subcommand name
// each fail structured (exit 2) instead of degrading to help + exit 0.
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
// A bare group (e.g. `sheets`), or one carrying only group-valid flags
@@ -383,28 +361,13 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
return cmd.Help()
}
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()),
Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
Detail: map[string]any{
// Keep the same detail keys as flagDidYouMean's unknown_flag
// so a consumer keyed on Type can read a stable shape. The
// subcommand isn't resolved here, so suggestions/valid_flags
// have no meaningful universe to draw from — emit empty
// rather than the group's own (misleading) flags. unknown is
// the back-compat singular field; unknown_flags carries the
// full list when more than one flag was supplied.
"unknown": strings.Join(unknown, ", "),
"unknown_flags": unknown,
"command_path": cmd.CommandPath(),
"suggestions": []string{},
"valid_flags": []string{},
},
},
verr := errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()).
WithHint("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath())
for _, flag := range unknown {
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "unknown flag before a subcommand"})
}
return verr
}
// The remaining flags are all defined somewhere in the tree. Those valid
// on the group itself or inherited (e.g. the global --profile) do not
@@ -416,19 +379,13 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
if len(misplaced) == 0 {
return cmd.Help()
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "missing_subcommand",
Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")),
Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
Detail: map[string]any{
"command_path": cmd.CommandPath(),
"flags": misplaced,
"suggestions": []string{},
},
},
verr := errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")).
WithHint("run `%s --help` to list subcommands and their flags", cmd.CommandPath())
for _, flag := range misplaced {
verr.WithParams(errs.InvalidParam{Name: flag, Reason: "flag belongs to a subcommand, not the group"})
}
return verr
}
unknown := args[0]
available, deprecated := availableSubcommandNames(cmd)
@@ -442,27 +399,12 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
strings.Join(suggestions, ", "), cmd.CommandPath())
}
detail := map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"suggestions": suggestions,
"available": available,
}
// Only services with backward-compat aliases (currently sheets) carry a
// deprecated bucket; omit the key elsewhere so every other service's
// envelope is unchanged.
if len(deprecated) > 0 {
detail["deprecated"] = deprecated
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_subcommand",
Message: msg,
Hint: hint,
Detail: detail,
},
}
// Record the offending subcommand and its ranked candidates as a param with
// machine-readable Suggestions so an agent can retry without parsing the
// hint; the hint carries the same candidates as prose.
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
WithParams(errs.InvalidParam{Name: unknown, Reason: "unknown subcommand", Suggestions: suggestions}).
WithHint("%s", hint)
}
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
@@ -588,47 +530,34 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into the structured ErrorEnvelope: an
// unknown flag gets a focused "did you mean" hint plus the full valid-flag list
// in detail (so agents recover even when the typo is semantic, e.g. --query vs
// --find, where edit distance alone finds nothing). Other flag errors stay
// structured but generic.
// converts cobra's flag-parse errors into a typed validation envelope: an
// unknown flag gets a focused "did you mean" hint (so agents recover even when
// the typo is semantic, e.g. --query vs --find, where edit distance alone finds
// nothing) and the offending flag in `params`. Other flag errors stay typed
// but generic.
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "flag_error",
Message: ferr.Error(),
Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()),
},
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
if len(suggestions) > 0 {
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
strings.Join(suggestions, ", "), c.CommandPath())
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
Hint: hint,
Detail: map[string]any{
"unknown": "--" + name,
"command_path": c.CommandPath(),
"suggestions": suggestions,
"valid_flags": valid,
},
},
}
// The ranked candidates ride on the param as machine-readable Suggestions so
// an agent can retry without parsing the hint; the hint carries the same
// candidates as prose. The full valid-flag list stays recoverable via --help.
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
WithHint("%s", hint)
}
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
@@ -698,56 +627,3 @@ func installTipsHelpFunc(root *cobra.Command) {
}
})
}
// enrichPermissionError rewrites the legacy *output.ExitError envelope so its
// Message + Hint match the per-subtype canonical text produced by the typed
// dispatcher path (errclass.CanonicalPermissionMessage / errclass.PermissionHint).
// This guarantees a caller observing the wire envelope cannot tell whether
// the error reached the dispatcher via the legacy *ExitError bridge or via
// the typed *errs.PermissionError fast path.
//
// Deprecated: legacy *output.ExitError enrichment; typed PermissionError
// values produced by errclass.BuildAPIError already carry MissingScopes +
// ConsoleURL directly.
func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr.Detail == nil {
return
}
// Only the legacy permission-class envelope types route here. "app_status"
// covers 99991662 (app_disabled) / 99991673 (app_unavailable); "permission"
// covers the four scope-class codes (99991672 / 99991676 / 99991679 / 230027).
if exitErr.Detail.Type != "permission" && exitErr.Detail.Type != "app_status" {
return
}
larkCode := exitErr.Detail.Code
meta, ok := errclass.LookupCodeMeta(larkCode)
if !ok || meta.Category != errs.CategoryAuthorization {
return
}
// Extract required scopes from API error detail (shared helper). May be
// empty for app-status codes — canonical message + hint still apply.
missing := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
cfg, err := f.Config()
if err != nil {
return
}
// Reuse the same console URL builder as the typed path so both wire
// envelopes carry identical console_url values for the same input.
consoleURL := errclass.ConsoleURL(string(cfg.Brand), cfg.AppID, missing)
// Clear raw API detail — useful info is now in message/hint/console_url.
exitErr.Detail.Detail = nil
identity := string(f.ResolvedIdentity)
if identity == "" {
identity = "user"
}
exitErr.Detail.Message = errclass.CanonicalPermissionMessage(meta.Subtype, cfg.AppID, missing, exitErr.Detail.Message)
exitErr.Detail.Hint = errclass.PermissionHint(missing, identity, meta.Subtype, consoleURL)
exitErr.Detail.ConsoleURL = consoleURL
}

View File

@@ -8,7 +8,6 @@ import (
"context"
"encoding/json"
"os"
"reflect"
"strings"
"testing"
@@ -27,12 +26,12 @@ import (
"github.com/spf13/cobra"
)
// Canonical strict-mode envelope strings shared across fixtures
// (reflect.DeepEqual pins them; keep in sync with strictModeStubFrom).
// Canonical strict-mode envelope messages shared across fixtures. The
// switch-policy hint text is asserted by substring in
// assertStrictModeDenialEnvelope.
const (
strictModeBotMessage = `strict mode is "bot", only bot-identity commands are available`
strictModeUserMessage = `strict mode is "user", only user-identity commands are available`
strictModeHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)"
)
// buildIntegrationRootCmd creates a root command with api, service, and shortcut
@@ -63,37 +62,46 @@ func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Com
return 0
}
// parseEnvelope parses stderr bytes into an ErrorEnvelope.
func parseEnvelope(t *testing.T, stderr *bytes.Buffer) output.ErrorEnvelope {
// typedErrorEnvelope mirrors the typed wire shape produced by
// WriteTypedErrorEnvelope: the inner error marshals an errs.Problem
// directly, so "type" is the category, "subtype" is top-level, and there
// is no nested "detail" object. Recovery info (policy source, reason
// code, suggestions) is folded into "hint".
type typedErrorEnvelope struct {
OK bool `json:"ok"`
Identity string `json:"identity,omitempty"`
Error struct {
Type string `json:"type"`
Subtype string `json:"subtype"`
Message string `json:"message"`
Hint string `json:"hint"`
Param string `json:"param,omitempty"`
} `json:"error"`
}
// parseTypedEnvelope decodes stderr as the typed envelope and fails if the
// legacy nested "detail" object is present (the migration removed it).
func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
t.Helper()
if stderr.Len() == 0 {
t.Fatal("expected non-empty stderr, got empty")
}
var env output.ErrorEnvelope
var raw map[string]any
if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil {
t.Fatalf("failed to parse stderr as JSON: %v\nstderr: %s", err, stderr.String())
}
if errObj, ok := raw["error"].(map[string]any); ok {
if _, hasDetail := errObj["detail"]; hasDetail {
t.Errorf("typed envelope must not carry a nested 'detail' object, got: %s", stderr.String())
}
}
var env typedErrorEnvelope
if err := json.Unmarshal(stderr.Bytes(), &env); err != nil {
t.Fatalf("failed to parse stderr as ErrorEnvelope: %v\nstderr: %s", err, stderr.String())
t.Fatalf("failed to parse stderr as typed envelope: %v\nstderr: %s", err, stderr.String())
}
return env
}
// assertEnvelope verifies exit code, stdout is empty, and stderr matches the
// expected ErrorEnvelope exactly via reflect.DeepEqual.
func assertEnvelope(t *testing.T, code int, wantCode int, stdout *bytes.Buffer, stderr *bytes.Buffer, want output.ErrorEnvelope) {
t.Helper()
if code != wantCode {
t.Errorf("exit code: got %d, want %d", code, wantCode)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
got := parseEnvelope(t, stderr)
if !reflect.DeepEqual(got, want) {
gotJSON, _ := json.MarshalIndent(got, "", " ")
wantJSON, _ := json.MarshalIndent(want, "", " ")
t.Errorf("stderr envelope mismatch:\ngot:\n%s\nwant:\n%s", gotJSON, wantJSON)
}
}
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
t.Helper()
rootCmd := &cobra.Command{Use: "lark-cli"}
@@ -205,23 +213,71 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop
// auth login is user-only, so it gets pruned in strict-mode-bot and the
// stub error fires (not login.go's inline check, which is shadowed by
// pruning).
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeBotMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "auth/login",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeBotMessage,
},
},
})
// pruning). The typed envelope is a failed_precondition validation
// error (exit 2); the strict-mode layer + reason code are folded into
// the hint.
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertStrictModeDenialEnvelope(t, env, strictModeBotMessage)
}
// assertStrictModeDenialEnvelope pins the shared strict-mode denial shape:
// a validation/failed_precondition envelope whose message is the short
// historical strict-mode line and whose hint still names the strict_mode
// layer + identity_not_supported reason code (the safety-critical recovery
// info), plus the historical switch-policy guidance.
func assertStrictModeDenialEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) {
t.Helper()
if env.OK {
t.Errorf("envelope ok = true, want false")
}
if env.Error.Type != "validation" {
t.Errorf("error.type = %q, want validation", env.Error.Type)
}
if env.Error.Subtype != "failed_precondition" {
t.Errorf("error.subtype = %q, want failed_precondition", env.Error.Subtype)
}
if env.Error.Message != wantMessage {
t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage)
}
if !strings.Contains(env.Error.Hint, "strict_mode") {
t.Errorf("error.hint = %q, want substring strict_mode (policy layer)", env.Error.Hint)
}
if !strings.Contains(env.Error.Hint, "identity_not_supported") {
t.Errorf("error.hint = %q, want substring identity_not_supported (reason code)", env.Error.Hint)
}
if !strings.Contains(env.Error.Hint, "config strict-mode --help") {
t.Errorf("error.hint = %q, want historical switch-policy guidance", env.Error.Hint)
}
}
// assertCheckStrictModeEnvelope pins the typed envelope produced by
// cmdutil.Factory.CheckStrictMode (the identity-guard path for explicit
// --as on shortcuts / service methods / api): a *errs.ValidationError with
// subtype invalid_argument, the canonical strict-mode message, and the
// switch-policy hint.
func assertCheckStrictModeEnvelope(t *testing.T, env typedErrorEnvelope, wantMessage string) {
t.Helper()
if env.OK {
t.Errorf("envelope ok = true, want false")
}
if env.Error.Type != "validation" {
t.Errorf("error.type = %q, want validation", env.Error.Type)
}
if env.Error.Subtype != "invalid_argument" {
t.Errorf("error.subtype = %q, want invalid_argument", env.Error.Subtype)
}
if env.Error.Message != wantMessage {
t.Errorf("error.message = %q, want %q", env.Error.Message, wantMessage)
}
if !strings.Contains(env.Error.Hint, "config strict-mode --help") {
t.Errorf("error.hint = %q, want switch-policy guidance", env.Error.Hint)
}
}
func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnvelope(t *testing.T) {
@@ -232,22 +288,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
"im", "+messages-search", "--chat-id", "oc_xxx", "--query", "hello",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeBotMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "im/+messages-search",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeBotMessage,
},
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertStrictModeDenialEnvelope(t, env, strictModeBotMessage)
}
func TestIntegration_StrictModeUser_ProfileOverride_ChatCreateDryRunSucceeds(t *testing.T) {
@@ -277,15 +325,14 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
"im", "+chat-create", "--name", "probe", "--as", "bot", "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "validation",
Message: `strict mode is "user", only user-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertCheckStrictModeEnvelope(t, env, strictModeUserMessage)
}
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
@@ -296,15 +343,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "validation",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertCheckStrictModeEnvelope(t, env, strictModeBotMessage)
}
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
@@ -315,22 +361,14 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeUserMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "im/images/create",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeUserMessage,
},
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertStrictModeDenialEnvelope(t, env, strictModeUserMessage)
}
func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelope(t *testing.T) {
@@ -341,15 +379,14 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
"api", "--as", "user", "GET", "/open-apis/im/v1/chats/oc_test", "--dry-run",
})
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "validation",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
if code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", code, output.ExitValidation)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
env := parseTypedEnvelope(t, stderr)
assertCheckStrictModeEnvelope(t, env, strictModeBotMessage)
}
// --- shortcut command ---
@@ -372,16 +409,43 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
})
// shortcut: typed error via DoAPIJSON path
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api",
Code: 230002,
Message: "Bot/User can NOT be out of the chat.",
},
})
// shortcut: typed errs.APIError via the CallAPITyped → BuildAPIError path.
if code != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI)", code, output.ExitAPI)
}
if stdout.Len() != 0 {
t.Errorf("expected empty stdout, got:\n%s", stdout.String())
}
if stderr.Len() == 0 {
t.Fatal("expected non-empty stderr, got empty")
}
var raw struct {
OK bool `json:"ok"`
Identity string `json:"identity"`
Error struct {
Type string `json:"type"`
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(stderr.Bytes(), &raw); err != nil {
t.Fatalf("failed to parse typed envelope: %v\nstderr: %s", err, stderr.String())
}
if raw.OK {
t.Errorf("envelope ok = true, want false")
}
if raw.Identity != "bot" {
t.Errorf("identity = %q, want bot", raw.Identity)
}
if raw.Error.Type != "api" {
t.Errorf("error.type = %q, want api", raw.Error.Type)
}
if raw.Error.Code != 230002 {
t.Errorf("error.code = %d, want 230002", raw.Error.Code)
}
if raw.Error.Message != "Bot/User can NOT be out of the chat." {
t.Errorf("error.message = %q, want %q", raw.Error.Message, "Bot/User can NOT be out of the chat.")
}
}
// TestSetupNotices_ColdStart_NoNotice verifies that missing state

View File

@@ -137,9 +137,6 @@ func TestIsCompletionCommand(t *testing.T) {
}
}
// TestPromoteConfigError_* lives with the implementation in
// internal/errcompat/promote_test.go.
// TestHandleRootError_SecurityPolicyCanonicalEnvelope verifies that
// *errs.SecurityPolicyError flows through the canonical typed envelope
// (output.WriteTypedErrorEnvelope) — type=policy, numeric code, subtype,
@@ -269,12 +266,11 @@ func (f *failingWriter) Write(p []byte) (int, error) {
return len(p), nil
}
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a
// backward-compat alias that fails on a cobra-level required flag (which
// short-circuits before RunE) still routes through the structured envelope,
// because OnInvoke records the deprecation in PreRunE and the legacy fallback
// switches to WriteErrorEnvelope when a deprecation is pending — so the
// migration notice is no longer dropped on the plain "Error:" line.
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins that a
// backward-compat alias failing on a cobra-level required flag (which
// short-circuits before RunE) routes through the structured envelope, so the
// deprecation notice OnInvoke records in PreRunE is carried on the wire instead
// of being dropped on a plain "Error:" line.
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
@@ -286,9 +282,9 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
deprecation.SetPending(&deprecation.Notice{
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
})
// The bare error shape cobra's ValidateRequiredFlags produces: neither typed
// nor an *output.ExitError, so it reaches the legacy fallback.
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
// The bare error shape cobra's ValidateRequiredFlags produces: not a typed
// errs.* error, so it reaches the deprecation fallback.
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
@@ -297,12 +293,96 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
}
// The envelope is typed validation, so the exit code must derive from that
// category (2) — the wire type and the exit code must not disagree.
if exit != int(output.ExitValidation) {
t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation))
}
}
// TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no
// deprecation pending, the legacy fallback stays a plain "Error:" line, so the
// fix does not reshape every unrecognized cobra error.
func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
// TestHandleRootError_AuthConfigWireGolden is the wire-consistency regression
// baseline for auth/config errors: it pins the typed envelope and exit code the
// dispatcher produces for the two source-of-truth shapes, which are constructed
// typed at their origin in internal/auth and internal/core.
func TestHandleRootError_AuthConfigWireGolden(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Run("token missing exits 3 with token_missing authentication envelope", func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, internalauth.NewNeedUserAuthorizationError("u_golden"))
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
}
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "authentication" {
t.Errorf("error.type = %v, want %q", got, "authentication")
}
if got := errObj["subtype"]; got != "token_missing" {
t.Errorf("error.subtype = %v, want %q", got, "token_missing")
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "need_user_authorization") {
t.Errorf("error.message = %q, must keep the need_user_authorization marker", got)
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "u_golden") {
t.Errorf("error.message = %q, must carry the user open id", got)
}
if got, _ := errObj["hint"].(string); !strings.Contains(got, "auth login") {
t.Errorf("error.hint = %q, must point at auth login", got)
}
if got := errObj["user_open_id"]; got != "u_golden" {
t.Errorf("error.user_open_id = %v, want %q", got, "u_golden")
}
})
t.Run("not configured exits 3 with not_configured config envelope", func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, core.NotConfiguredError())
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (config shares ExitAuth)", exit, int(output.ExitAuth))
}
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "config" {
t.Errorf("error.type = %v, want %q", got, "config")
}
if got := errObj["subtype"]; got != "not_configured" {
t.Errorf("error.subtype = %v, want %q", got, "not_configured")
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "not configured") {
t.Errorf("error.message = %q, want the not-configured message", got)
}
if got, _ := errObj["hint"].(string); !strings.Contains(got, "config init") {
t.Errorf("error.hint = %q, must point at config init", got)
}
})
}
// decodeErrorEnvelope unmarshals a typed error envelope and returns its
// top-level "error" object, failing the test if the shape is unexpected.
func decodeErrorEnvelope(t *testing.T, raw []byte) map[string]any {
t.Helper()
var env map[string]any
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("envelope is not valid JSON: %v\n%s", err, raw)
}
errObj, ok := env["error"].(map[string]any)
if !ok {
t.Fatalf("envelope missing top-level error object: %s", raw)
}
return errObj
}
// TestHandleRootError_NoDeprecationTypesUsageError pins that a residual cobra
// usage error (missing required flag) is typed as invalid_argument with exit 2
// even with no deprecation pending — never cobra's plain "Error:" line.
func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
@@ -311,9 +391,45 @@ func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
if !strings.HasPrefix(errOut.String(), "Error:") {
t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String())
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
t.Fatalf("want a structured envelope, got a plain Error: line:\n%s", out)
}
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "validation" {
t.Errorf("error.type = %v, want %q", got, "validation")
}
if got, _ := errObj["message"].(string); !strings.Contains(got, "values") {
t.Errorf("error.message = %q, must carry the failing flag name", got)
}
if exit != int(output.ExitValidation) {
t.Errorf("exit = %d, want %d (validation envelope → category-derived exit)", exit, int(output.ExitValidation))
}
}
// TestHandleRootError_LeakedUntypedErrorBecomesInternal pins that an untyped
// error that does NOT match a cobra usage shape (i.e. one that leaked past the
// typed boundary from a helper) is classified as an internal fault (exit 5),
// not blamed on the user's input as a validation error.
func TestHandleRootError_LeakedUntypedErrorBecomesInternal(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, fmt.Errorf("upstream helper exploded: %w", io.ErrUnexpectedEOF))
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "internal" {
t.Errorf("error.type = %v, want %q (leaked untyped error must not be mislabeled validation)", got, "internal")
}
if exit != int(output.ExitInternal) {
t.Errorf("exit = %d, want %d (internal envelope → category-derived exit)", exit, int(output.ExitInternal))
}
}
@@ -337,12 +453,32 @@ func TestHandleRootError_PartialWritePreservesExitCode(t *testing.T) {
}
}
// TestHandleRootError_TypedOuterShortCircuitsPromote pins that when a typed
// *errs.AuthenticationError carries a legacy *NeedAuthorizationError in its
// Cause chain, the dispatcher does NOT run PromoteAuthError — doing so
// would replace the producer's TokenExpired subtype + custom hint with the
// promoted shape's TokenMissing.
func TestHandleRootError_TypedOuterShortCircuitsPromote(t *testing.T) {
// TestHandleRootError_BareErrorExitCodeNoStderr pins the silent-exit
// contract: a *output.BareError is honored for its exit code while stderr stays
// empty (stdout already carries the result, so the dispatcher must not layer a
// second envelope on top).
func TestHandleRootError_BareErrorExitCodeNoStderr(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, output.ErrBare(output.ExitAuth))
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (BareError code propagated)", exit, int(output.ExitAuth))
}
if errOut.Len() != 0 {
t.Errorf("stderr must stay empty for a bare predicate signal, got:\n%s", errOut.String())
}
}
// TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved pins that a typed
// *errs.AuthenticationError carrying a legacy *NeedAuthorizationError in its
// Cause chain renders the producer's TokenExpired subtype + custom hint
// verbatim — the legacy sentinel in the Cause chain never coarsens the wire
// shape.
func TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -494,136 +630,3 @@ func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
t.Errorf("expected appended hint %q, got %q", want, authErr.Hint)
}
}
// TestEnrichPermissionError_CanonicalConvergence pins that the legacy
// *output.ExitError dispatch path produces the same canonical Message + Hint
// + ConsoleURL as the typed *errs.PermissionError dispatch path. Both paths
// share errclass.CanonicalPermissionMessage / errclass.PermissionHint /
// errclass.ConsoleURL — so a wire consumer cannot tell which path produced
// the envelope.
func TestEnrichPermissionError_CanonicalConvergence(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cases := []struct {
name string
larkCode int
legacyErrType string
wantMsgSubstrs []string
wantHintSubstrs []string
wantConsoleURL bool
wantNoAuthLogin bool // hint must not suggest `auth login`
}{
{
name: "99991672 app_scope_not_applied",
larkCode: 99991672,
legacyErrType: "permission",
wantMsgSubstrs: []string{"access denied", "app cli_test", "drive:drive:read"},
wantHintSubstrs: []string{"developer console", "open.feishu.cn"},
wantConsoleURL: true,
wantNoAuthLogin: true,
},
{
name: "99991679 missing_scope",
larkCode: 99991679,
legacyErrType: "permission",
wantMsgSubstrs: []string{"unauthorized", "user authorization"},
wantHintSubstrs: []string{"lark-cli auth login"},
},
{
name: "99991673 app_unavailable",
larkCode: 99991673,
legacyErrType: "app_status",
wantMsgSubstrs: []string{"unauthorized app", "app cli_test", "not properly installed"},
wantHintSubstrs: []string{"tenant admin", "install status"},
},
{
name: "99991662 app_disabled",
larkCode: 99991662,
legacyErrType: "app_status",
wantMsgSubstrs: []string{"app cli_test", "not in use", "currently disabled"},
wantHintSubstrs: []string{"tenant admin", "re-enable"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
// Mimic the wire shape ErrAPI produces: legacy *ExitError with
// Detail.Type populated by ClassifyLarkError, Detail.Detail
// carrying the permission_violations block so ExtractRequiredScopes
// can recover the missing scope.
scopeForDetail := "drive:drive:read"
exitErr := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: tc.legacyErrType,
Code: tc.larkCode,
Message: "upstream raw message — must be replaced",
Detail: map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": scopeForDetail},
},
},
},
}
enrichPermissionError(f, exitErr)
for _, sub := range tc.wantMsgSubstrs {
if !strings.Contains(exitErr.Detail.Message, sub) {
t.Errorf("Message %q missing substring %q", exitErr.Detail.Message, sub)
}
}
if exitErr.Detail.Message == "upstream raw message — must be replaced" {
t.Errorf("Message must be rewritten to canonical text; got upstream verbatim")
}
for _, sub := range tc.wantHintSubstrs {
if !strings.Contains(exitErr.Detail.Hint, sub) {
t.Errorf("Hint %q missing substring %q", exitErr.Detail.Hint, sub)
}
}
if tc.wantNoAuthLogin && strings.Contains(exitErr.Detail.Hint, "auth login") {
t.Errorf("Hint must not suggest `auth login` for this subtype; got %q", exitErr.Detail.Hint)
}
if tc.wantConsoleURL && exitErr.Detail.ConsoleURL == "" {
t.Error("ConsoleURL should be populated when missing scopes are present")
}
})
}
}
// TestEnrichPermissionError_SkipsUnrelatedTypes pins that an ExitError whose
// Detail.Type is neither "permission" nor "app_status" is left untouched —
// no Message rewrite, no Hint rewrite, no ConsoleURL injection.
func TestEnrichPermissionError_SkipsUnrelatedTypes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
for _, ty := range []string{"api_error", "validation", "rate_limit", "auth"} {
exitErr := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: ty,
Code: 99991400,
Message: "untouched",
Hint: "original hint",
},
}
enrichPermissionError(f, exitErr)
if exitErr.Detail.Message != "untouched" {
t.Errorf("type=%q: Message was rewritten unexpectedly: %q", ty, exitErr.Detail.Message)
}
if exitErr.Detail.Hint != "original hint" {
t.Errorf("type=%q: Hint was rewritten unexpectedly: %q", ty, exitErr.Detail.Hint)
}
if exitErr.Detail.ConsoleURL != "" {
t.Errorf("type=%q: ConsoleURL should not be injected; got %q", ty, exitErr.Detail.ConsoleURL)
}
}
}

View File

@@ -5,9 +5,11 @@ package schema
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
@@ -209,6 +211,45 @@ func TestSchemaCmd_UnknownService(t *testing.T) {
if !strings.Contains(err.Error(), "Unknown service") {
t.Errorf("expected 'Unknown service' error, got: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(ve.Hint, "Available:") {
t.Errorf("expected hint listing available services, got: %q", ve.Hint)
}
}
// TestSchemaCmd_UnknownMethod_TypedValidation pins the typed envelope for the
// JSON-mode unknown-method path: *errs.ValidationError with
// subtype invalid_argument and a hint listing the available methods.
func TestSchemaCmd_UnknownMethod_TypedValidation(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"calendar.events.nonexistent_method"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for unknown method")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(err.Error(), "Unknown method") {
t.Errorf("expected 'Unknown method' error, got: %v", err)
}
if !strings.Contains(ve.Hint, "Available:") {
t.Errorf("expected hint listing available methods, got: %q", ve.Hint)
}
}
// Completion candidate generation (dotted + space forms, strict-mode filtering,

View File

@@ -13,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
@@ -32,13 +33,16 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
}
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
RegisterServiceCommandsFromCatalog(ctx, parent, f, registry.RuntimeCatalog())
}
func RegisterServiceCommandsFromCatalog(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory, catalog apicatalog.Catalog) {
// Drive the service list from the same navigation catalog the method walk
// uses — RuntimeCatalog().Services() is the deterministic, sorted view of the
// merged metadata — so registration is catalog-sourced end to end. Kept as a
// per-service loop rather than a flat WalkMethods(nil) drive precisely so a
// service with no methods still gets its bare command (WalkMethods yields one
// ref per method, so empty services would vanish).
for _, svc := range registry.RuntimeCatalog().Services() {
// uses, so registration is catalog-sourced end to end. Kept as a per-service
// loop rather than a flat WalkMethods(nil) drive precisely so a service with
// no methods still gets its bare command (WalkMethods yields one ref per
// method, so empty services would vanish).
for _, svc := range catalog.Services() {
if svc.Name == "" || svc.ServicePath == "" {
continue
}
@@ -84,10 +88,12 @@ func serviceShort(svc meta.Service) string {
func ensureChildCommand(parent *cobra.Command, name, short string) *cobra.Command {
for _, c := range parent.Commands() {
if c.Name() == name {
cmdmeta.SetSource(c, cmdmeta.SourceService, true)
return c
}
}
cmd := &cobra.Command{Use: name, Short: short}
cmdmeta.SetSource(cmd, cmdmeta.SourceService, true)
parent.AddCommand(cmd)
return cmd
}
@@ -231,6 +237,7 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
return serviceMethodRun(opts)
},
}
cmdmeta.SetSource(cmd, cmdmeta.SourceService, true)
cmd.Flags().StringVar(&opts.Params, "params", "", "Raw URL/query params JSON. Supports - and @file.")
if spec.acceptsBody {
@@ -380,7 +387,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
checkErr := ac.CheckResponse
if opts.PageAll {
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr)
}
@@ -620,20 +627,45 @@ func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *cor
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
scanResult := output.ScanForSafety(commandPath, items, errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
output.WriteAlertWarning(errOut, scanResult.Alert)
}
pf.FormatPage(items)
return nil
}, pagOpts)
if err != nil {
return err
@@ -643,7 +675,12 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
output.FormatValue(out, result, output.FormatJSON)
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
return nil
default:
@@ -652,9 +689,14 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
output.FormatValue(out, result, format)
return nil
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
}

View File

@@ -4,10 +4,15 @@
package service
import (
"context"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -407,8 +412,19 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "success") {
t.Errorf("expected 'success' in output, got:\n%s", stdout.String())
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
if got["ok"] != true || got["identity"] != "bot" {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if !ok || data["result"] != "success" {
t.Fatalf("data = %#v, want result=success", got["data"])
}
}
@@ -436,8 +452,312 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), `"id"`) {
t.Errorf("expected items in output, got:\n%s", stdout.String())
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("data.items = %#v, want one item", data["items"])
}
}
type serviceContentSafetyProvider struct {
called bool
path string
data interface{}
match string
}
func (p *serviceContentSafetyProvider) Name() string { return "service-test" }
func (p *serviceContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
p.called = true
p.path = req.Path
p.data = req.Data
if p.match != "" {
b, _ := json.Marshal(req.Data)
if !strings.Contains(string(b), p.match) {
return nil, nil
}
}
return &extcs.Alert{Provider: "service-test", MatchedRules: []string{"pagination"}}, nil
}
func TestServiceMethod_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &serviceContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-safety", AppSecret: "test-secret-service-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
root.SetArgs([]string{"list", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan paginated output")
}
if provider.path != "list" {
t.Fatalf("scan path = %q, want list", provider.path)
}
data, ok := provider.data.(map[string]interface{})
if !ok {
t.Fatalf("scanned data type = %T, want map", provider.data)
}
if _, hasCode := data["code"]; hasCode {
t.Fatalf("scanned data should be business data only, got %#v", data)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
alert, ok := got["_content_safety_alert"].(map[string]interface{})
if !ok || alert["provider"] != "service-test" {
t.Fatalf("missing content safety alert in envelope: %#v", got)
}
}
func TestServiceMethod_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &serviceContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-stream-safety", AppSecret: "test-secret-service-stream-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
root.SetArgs([]string{"list", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan streamed paginated output")
}
if provider.path != "list" {
t.Fatalf("scan path = %q, want list", provider.path)
}
items, ok := provider.data.([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
}
if !strings.Contains(stderr.String(), "warning: content safety alert from service-test") {
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
}
if !strings.Contains(stdout.String(), `"id":"1"`) {
t.Fatalf("expected streamed ndjson output, got: %s", stdout.String())
}
}
func TestServiceMethod_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
provider := &serviceContentSafetyProvider{match: "blocked"}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-stream-block", AppSecret: "test-secret-service-stream-block", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "blocked-page"}},
"has_more": false,
},
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdServiceMethod(f, spec, method, "list", "items", nil))
root.SetArgs([]string{"list", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute()
if err == nil {
t.Fatal("expected content safety block error")
}
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
}
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
}
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "pagination" {
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
}
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
}
if strings.Contains(out, "blocked-page") {
t.Fatalf("blocked page was written before safety block: %s", out)
}
}
func TestServiceMethod_BusinessErrorReturnsTypedErrorWithoutSuccessEnvelope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-err", AppSecret: "test-secret-service-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected PermissionError, got %T: %v", err, err)
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
}
func TestServiceMethod_PageAll_DefaultBusinessErrorOutputsRawResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-pageall-err", AppSecret: "test-secret-service-pageall-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
if !strings.Contains(stdout.String(), "230027") || !strings.Contains(stdout.String(), "user not authorized") {
t.Fatalf("expected raw error response on stdout, got: %s", stdout.String())
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
}
func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-service-pageall-stream-err", AppSecret: "test-secret-service-pageall-stream-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027,
"msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier successful page to remain streamed, got: %s", out)
}
if strings.Contains(out, "230027") || strings.Contains(out, "user not authorized") {
t.Fatalf("streaming stdout should not contain raw error JSON, got: %s", out)
}
if strings.Contains(out, "\n \"code\"") {
t.Fatalf("streaming stdout should not contain indented JSON error dump, got: %s", out)
}
}
@@ -629,6 +949,51 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
}
}
func TestServiceMethod_PageAll_WithJqBusinessErrorOutputsRawResponse(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-spjq-err", AppSecret: "test-secret-spjq-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected PermissionError, got %T: %v", err, err)
}
if !strings.Contains(stdout.String(), "230027") || !strings.Contains(stdout.String(), "user not authorized") {
t.Fatalf("expected raw error response on stdout, got: %s", stdout.String())
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
}
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, code int) {
t.Helper()
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != category || p.Subtype != subtype || p.Code != code {
t.Fatalf("problem = %s/%s/%d, want %s/%s/%d", p.Category, p.Subtype, p.Code, category, subtype, code)
}
}
// ── file upload ──
func imImageMethod() meta.Method {

View File

@@ -11,6 +11,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
@@ -126,29 +127,20 @@ func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
}
// The detail must stay schema-compatible with flagDidYouMean's unknown_flag
// (same Type → same keys), so a consumer keyed on Type reads a stable shape.
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError with Detail, got %T", err)
// Typed surface: a validation error (exit 2) whose Params carries the
// offending flag so an agent can recover the token without parsing prose.
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type)
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("expected detail to be map[string]any, got %T", exitErr.Detail.Detail)
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
}
if detail["unknown"] != "--badflag" {
t.Errorf("detail.unknown = %v, want --badflag", detail["unknown"])
}
if got, _ := detail["unknown_flags"].([]string); len(got) != 1 || got[0] != "--badflag" {
t.Errorf("detail.unknown_flags = %v, want [--badflag]", detail["unknown_flags"])
}
for _, key := range []string{"suggestions", "valid_flags"} {
if _, present := detail[key]; !present {
t.Errorf("detail.%s missing; must be present (empty) to match the unknown_flag schema", key)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--badflag" {
t.Errorf("params = %v, want one entry named --badflag", verr.Params)
}
}
@@ -172,25 +164,21 @@ func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing
if err == nil {
t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" {
t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail)
if !strings.Contains(verr.Message, "missing subcommand") {
t.Errorf("message = %q, want it to mention a missing subcommand", verr.Message)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
if len(verr.Params) != 1 || verr.Params[0].Name != "--query" {
t.Errorf("params = %v, want one entry named --query", verr.Params)
}
if flags, _ := detail["flags"].([]string); len(flags) != 1 || flags[0] != "--query" {
t.Errorf("detail.flags = %v, want [--query]", detail["flags"])
}
if detail["command_path"] != "lark-cli drive" {
t.Errorf("detail.command_path = %v, want lark-cli drive", detail["command_path"])
if !strings.Contains(verr.Message, "lark-cli drive") {
t.Errorf("message = %q, want it to name the group path", verr.Message)
}
}
@@ -241,45 +229,23 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
t.Fatal("expected error for unknown subcommand")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("expected exit code %d, got %d", output.ExitValidation, exitErr.Code)
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("expected exit code %d, got %d", output.ExitValidation, output.ExitCodeOf(err))
}
if exitErr.Detail == nil {
t.Fatal("expected ExitError to carry Detail")
if !strings.Contains(verr.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", verr.Message)
}
if exitErr.Detail.Type != "unknown_subcommand" {
t.Errorf("expected Detail.Type=unknown_subcommand, got %q", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
if !strings.Contains(verr.Message, "lark-cli drive") {
t.Errorf("message should name the group path, got %q", verr.Message)
}
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
// back to pointing at --help; the full machine-readable list lives in
// detail.available below (which also excludes hidden commands).
if !strings.Contains(exitErr.Detail.Hint, "--help") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("expected Detail.Detail to be map[string]any, got %T", exitErr.Detail.Detail)
}
if detail["unknown"] != "+bogus" {
t.Errorf("detail.unknown should be +bogus, got %v", detail["unknown"])
}
if detail["command_path"] != "lark-cli drive" {
t.Errorf("detail.command_path should be %q, got %v", "lark-cli drive", detail["command_path"])
}
available, ok := detail["available"].([]string)
if !ok {
t.Fatalf("detail.available should be []string, got %T", detail["available"])
}
if len(available) != 3 {
t.Errorf("expected 3 available entries (hidden excluded), got %d: %v", len(available), available)
// back to pointing at --help (suggestions, when present, are folded into hint).
if !strings.Contains(verr.Hint, "--help") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", verr.Hint)
}
}
@@ -288,13 +254,12 @@ func TestUnknownSubcommandRunE_NestedResourceGroup(t *testing.T) {
installUnknownSubcommandGuard(root)
err := files.RunE(files, []string{"bogus"})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError on nested group, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError on nested group, got %T", err)
}
if exitErr.Detail.Detail.(map[string]any)["command_path"] != "lark-cli drive files" {
t.Errorf("command_path should reflect the nested resource, got %v",
exitErr.Detail.Detail.(map[string]any)["command_path"])
if !strings.Contains(verr.Message, "lark-cli drive files") {
t.Errorf("message should reflect the nested resource path, got %q", verr.Message)
}
}
@@ -337,10 +302,10 @@ func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
}
}
// unknownSubcommandRunE must split current vs deprecated subcommands into
// separate detail buckets, while suggestions still rank across both so a
// mistyped legacy alias resolves.
func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
// unknownSubcommandRunE ranks suggestions across both current and deprecated
// subcommands so a mistyped legacy alias resolves; the closest match is folded
// into the hint.
func TestUnknownSubcommandRunE_SuggestsAcrossDeprecatedBucket(t *testing.T) {
svc := &cobra.Command{Use: "sheets"}
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
svc.AddCommand(
@@ -349,31 +314,26 @@ func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
)
err := unknownSubcommandRunE(svc, []string{"+reat"})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
// "+reat" is closest to the deprecated +read: the candidate must surface
// both as a machine-readable param suggestion (for agent retry) and in the
// hint, proving ranking spans the deprecated bucket.
if len(verr.Params) != 1 || verr.Params[0].Name != "+reat" {
t.Fatalf("params = %v, want one entry named +reat (the offending subcommand)", verr.Params)
}
if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" {
t.Errorf("available = %v, want [+cells-get]", available)
}
deprecated, ok := detail["deprecated"].([]string)
if !ok || len(deprecated) != 1 || deprecated[0] != "+read" {
t.Errorf("deprecated = %v, want [+read]", deprecated)
}
// suggestions rank across both buckets: "+reat" is closest to +read.
suggestions, _ := detail["suggestions"].([]string)
found := false
for _, s := range suggestions {
foundSuggestion := false
for _, s := range verr.Params[0].Suggestions {
if s == "+read" {
found = true
foundSuggestion = true
}
}
if !found {
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
if !foundSuggestion {
t.Errorf("Params[0].Suggestions should include +read, got %v", verr.Params[0].Suggestions)
}
if !strings.Contains(verr.Hint, "+read") {
t.Errorf("hint %q should suggest +read (typo target across deprecated bucket)", verr.Hint)
}
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
@@ -132,12 +133,14 @@ func updateRun(opts *UpdateOptions) error {
// 1. Fetch latest version
latest, err := fetchLatest()
if err != nil {
return reportError(opts, io, output.ExitNetwork, "network", "failed to check latest version: %s", err)
return reportError(opts, io, "network",
errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to check latest version: %s", err).WithCause(err))
}
// 2. Validate version format
if update.ParseVersion(latest) == nil {
return reportError(opts, io, output.ExitInternal, "update_error", "invalid version from registry: %s", latest)
return reportError(opts, io, "update_error",
errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid version from registry: %s", latest))
}
// 3. Compare versions
@@ -166,15 +169,18 @@ func updateRun(opts *UpdateOptions) error {
// --- Output helpers ---
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, exitCode int, errType, format string, args ...interface{}) error {
msg := fmt.Sprintf(format, args...)
// reportError emits the failure on the requested surface: JSON mode prints the
// {ok:false, error:{type, message}} envelope to stdout and signals the typed
// error's exit code bare; human mode returns the typed error for the
// dispatcher to render.
func reportError(opts *UpdateOptions, io *cmdutil.IOStreams, errType string, typedErr errs.TypedError) error {
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{"type": errType, "message": msg},
"ok": false, "error": map[string]interface{}{"type": errType, "message": typedErr.ProblemDetail().Message},
})
return output.ErrBare(exitCode)
return output.ErrBare(output.ExitCodeOf(typedErr))
}
return output.Errorf(exitCode, errType, "%s", msg)
return typedErr
}
func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error {
@@ -228,7 +234,8 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
restore, err := updater.PrepareSelfReplace()
if err != nil {
return reportError(opts, io, output.ExitAPI, "update_error", "failed to prepare update: %s", err)
return reportError(opts, io, "update_error",
errs.NewAPIError(errs.SubtypeUnknown, "failed to prepare update: %s", err).WithCause(err))
}
if !opts.JSON {

View File

@@ -14,6 +14,7 @@ import (
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
@@ -334,13 +335,88 @@ func TestUpdateFetchError_Human(t *testing.T) {
if err == nil {
t.Fatal("expected non-nil error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var netErr *errs.NetworkError
if !errors.As(err, &netErr) {
t.Fatalf("expected *errs.NetworkError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, exitErr.Code)
if netErr.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("subtype = %q, want %q", netErr.Subtype, errs.SubtypeNetworkTransport)
}
if got := output.ExitCodeOf(err); got != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d), got %d", output.ExitNetwork, got)
}
}
// TestUpdateInvalidVersion_Human verifies a malformed registry version surfaces
// as a typed internal error in human mode, keeping the legacy exit code 5.
func TestUpdateInvalidVersion_Human(t *testing.T) {
f, _, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "not-a-version", nil }
defer func() { fetchLatest = origFetch }()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
if err == nil {
t.Fatal("expected non-nil error, got nil")
}
var intErr *errs.InternalError
if !errors.As(err, &intErr) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
if intErr.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", intErr.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("expected ExitInternal (%d), got %d", output.ExitInternal, got)
}
}
// TestReportError pins reportError's two surfaces after the typed migration:
// human mode returns the typed error unchanged; JSON mode prints the legacy
// {ok:false, error:{type, message}} envelope and exits bare with the typed
// error's exit code (parity with the legacy explicit exit-code argument).
func TestReportError(t *testing.T) {
t.Run("human mode returns the typed error", func(t *testing.T) {
f, _, _ := newTestFactory(t)
typed := errs.NewAPIError(errs.SubtypeUnknown, "failed to prepare update: disk full")
err := reportError(&UpdateOptions{JSON: false}, f.IOStreams, "update_error", typed)
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected *errs.APIError, got %T: %v", err, err)
}
if apiErr != typed {
t.Errorf("reportError must return the typed error unchanged")
}
if got := output.ExitCodeOf(err); got != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI, legacy parity)", got, output.ExitAPI)
}
})
t.Run("json mode prints envelope and exits bare with typed code", func(t *testing.T) {
f, stdout, _ := newTestFactory(t)
typed := errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to check latest version: timeout")
err := reportError(&UpdateOptions{JSON: true}, f.IOStreams, "network", typed)
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected bare *output.BareError, got %T: %v", err, err)
}
if bareErr.Code != output.ExitNetwork {
t.Errorf("bare exit code = %d, want %d", bareErr.Code, output.ExitNetwork)
}
out := stdout.String()
if !strings.Contains(out, `"type": "network"`) && !strings.Contains(out, `"type":"network"`) {
t.Errorf("JSON envelope missing type, got: %s", out)
}
if !strings.Contains(out, "failed to check latest version: timeout") {
t.Errorf("JSON envelope missing message, got: %s", out)
}
})
}
func TestUpdateInvalidVersion_JSON(t *testing.T) {
@@ -503,12 +579,12 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
if err == nil {
t.Fatal("expected verification failure")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, exitErr.Code)
if bareErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI (%d), got %d", output.ExitAPI, bareErr.Code)
}
out := stdout.String()

View File

@@ -6,25 +6,16 @@ envelope on stderr; **protocol adapters** mapping CLI errors into MCP /
OAuth shapes; and **framework + business code** producing errors. This file
is the single source of truth for all three.
This document describes the **typed authoring target**. The refactor lands
in stages; some boundaries (e.g. `client.WrapDoAPIError`) still operate on
legacy shapes today — see **Migration** for what is live in each stage.
Migrating an `*output.ExitError` call site? See **Migration**. Something off
in production? See **Troubleshooting**.
Something off in production? See **Troubleshooting**.
## Invariants
1. Every error belongs to exactly one **Category**. The set is closed
(`errs/category.go`); adding a member requires deliberate review.
2. Every **newly constructed** typed error has a **Subtype** — a stable
2. Every typed error has a **Subtype** — a stable
lowercase-with-underscores identifier declared in `errs/subtypes*.go`.
Undeclared subtypes fail CI. The constraint applies only to typed
`*errs.*` literals; stage-1 legacy `*core.ConfigError` flows via the
dispatcher's `asExitError` → legacy envelope path (not the typed
taxonomy) and is unaffected. `errcompat.PromoteConfigError` is a
stage-1 passthrough; its stage-2+ typed migration will subject the
promoted typed error to this Subtype constraint at that time.
Undeclared subtypes fail CI. Every error path constructs a typed
`*errs.*` error at its origin, so the constraint applies uniformly.
3. **`Category` + `Subtype`** are wire-stable identifiers consumers may
branch on. Renaming either is a breaking change.
4. `Code` is the upstream numeric code when known (e.g. Lark API code).
@@ -35,11 +26,10 @@ in production? See **Troubleshooting**.
unchanged across the `errors.As` / `errors.Unwrap` chain.
7. For the typed-envelope path, exit codes derive from `Category` only
via `output.ExitCodeForCategory` — including `SecurityPolicyError`,
which exits `6` via `CategoryPolicy`. Unmigrated `*output.ExitError`
producers still carry a hand-set `Code` until they finish migrating.
`output.ErrBare(code)` is the lone exception: a deliberate
predicate-command signal that bypasses the envelope (see
**Predicate commands** below).
which exits `6` via `CategoryPolicy`. `output.ErrBare(code)` is the
exception: it constructs an `*output.BareError`, a deliberate
silent-exit signal (stdout already carries the answer) that bypasses
the envelope (see **Predicate commands** below).
## Wire format
@@ -73,13 +63,14 @@ Typed errors render to **stderr** as one JSON object per process exit:
| `error.hint` | informational | actionable recovery guidance |
| `error.log_id` | informational | upstream request id (server-side trace) |
| `error.retryable` | wire-stable | `true` when present; omitted when `false` |
| `error.param` | per-Subtype-stable | single offending parameter (`ValidationError`); see **Validation parameters** |
| `error.params` | per-Subtype-stable | per-parameter validation detail array (`ValidationError`); see **Validation parameters** |
| per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
`SecurityPolicyError` renders through the same typed envelope as every
other category. `error.type` is `"policy"`, `error.subtype` is one of
`challenge_required` / `access_denied`, and process exit is `6` via
`CategoryPolicy`. The legacy `auth_error` envelope at exit `1` has been
retired.
`CategoryPolicy`.
## Categories
@@ -119,20 +110,21 @@ Canonical mapping: `internal/output/exitcode.go` `ExitCodeForCategory`.
cmd/root.go handleRootError dispatches:
├─ output.ErrBare(code) → no envelope (stdout already written); exit = code
├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err)
│ (includes *errs.SecurityPolicyError → policy envelope, exit 6)
├─ *core.ConfigError → promoted to typed via errcompat ↑
├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code
untyped / Cobra error → plain "Error: <msg>" (no envelope); exit 1
│ (includes *errs.SecurityPolicyError → policy envelope, exit 6;
│ *errs.ConfigError, constructed typed at origin)
├─ *output.PartialFailureError → no stderr envelope (ok:false result already on stdout); exit = code
*output.BareError → no envelope (stdout already written); exit = code
└─ Cobra usage error → typed validation envelope (invalid_argument); exit 2
```
Only the typed and `*output.ExitError` branches emit a JSON envelope on
stderr. Untyped errors (including Cobra's "required flag missing" / unknown
subcommand messages) print plain text and exit `1` — consumers must
tolerate that fallback.
The dispatcher emits a JSON envelope on stderr for both the typed branch and
residual Cobra usage errors (missing required flag, unknown command,
argument validation): the latter are classified into a typed validation
envelope (`invalid_argument`) and exit `2`, matching the explicit flag and
subcommand guards.
### Predicate commands (`output.ErrBare`)
### Predicate commands (`output.BareError`)
A small class of commands is **predicates**: they answer a yes/no
question and signal the answer through the shell exit code so callers
@@ -142,19 +134,27 @@ example — its `README` contract is `exit 0 = ok, 1 = missing`.
These commands deliberately:
1. write a structured JSON answer to **stdout** themselves, and
2. return `output.ErrBare(exitCode)` to communicate the exit code to
the dispatcher without producing a `stderr` envelope.
2. return `output.ErrBare(exitCode)` — an `*output.BareError` to
communicate the exit code to the dispatcher without producing a
`stderr` envelope.
`output.ErrBare` is **not** an error in the typed-envelope sense — it
carries no category, subtype, or message. It is a one-bit output-
control signal that lives outside the contract for the same reason
`grep -q` / `diff` / `systemctl is-active` set non-zero exit codes
without printing anything to stderr: pollution of stderr by a
`*output.BareError` is **not** an error in the typed-envelope sense — it
carries no category, subtype, or message, only an exit code. It is a
one-bit output-control signal that lives outside the contract for the
same reason `grep -q` / `diff` / `systemctl is-active` set non-zero exit
codes without printing anything to stderr: pollution of stderr by a
predicate's negative answer would break `2>/dev/null` log hygiene in
caller scripts.
New code should not reach for `ErrBare` unless the command is
genuinely a predicate. Anything carrying recoverable error content
A second class also uses `ErrBare`: a command that emits its own complete
structured result envelope on **stdout** under `--json` (e.g. `update`, whose
`{ok:false, error:{type, message}}` is its established output shape) and needs
only the exit code conveyed, with no `stderr` envelope. Like a predicate, its
answer is already on stdout; `ErrBare` carries the exit code alone.
New code should not reach for `ErrBare` unless the command's full answer is
already on stdout — a predicate's yes/no, or a self-contained result envelope
as above. Anything whose error content must reach the caller on `stderr`
belongs in a typed `*errs.XxxError` — or, for a batch result, in the
partial-failure outcome below.
@@ -214,7 +214,7 @@ exitCode := output.ExitCodeOf(err) // ExitInternal for non-typed errors
out=$(lark-cli ... 2>&1)
code=$?
# Untyped / Cobra errors print plain text — guard before jq.
# Defensive guard: tolerate any non-JSON output before parsing with jq.
if ! jq -e . >/dev/null 2>&1 <<<"$out"; then
printf '%s\n' "$out" >&2
exit "$code"
@@ -303,9 +303,10 @@ Do not pick exit codes by hand in new typed producers — `ExitCodeForCategory`
maps `Category` to the shell code. A new exit-code requirement means a
new `Category`, not a one-off override at the call site.
(Legacy `*output.ExitError` retains hand-set codes until removal;
`SecurityPolicyError` retains a hand-set code on main until the framework
migration PR retires the carve-out — see **Migration**.)
(The only exits not derived from `Category` are the
`*output.BareError` and the `*output.PartialFailureError` signals, which
carry their own code by design and sit outside the typed-envelope contract —
see **Predicate commands**.)
#### Split `Message`, `Hint`, and `Cause`
@@ -340,15 +341,54 @@ Message: fmt.Sprintf("request failed: %v — retry later", ioErr)
// conflates what + what-to-do + cause into one string
```
#### `ValidationError.Param` uses the `--flag` form
#### Validation parameters: `Param` and `Params`
When a `*ValidationError` originates from a flag value, `Param` holds the
flag name with leading dashes (`"--priority"`, not `"priority"`). AI
agents grep this field literally to surface "the bad flag was `--X`".
`ValidationError` carries two additive parameter fields. Both are
optional; a producer sets whichever fits the failure.
For positional arguments, use the canonical name without dashes
**`Param string` (wire `param`)** — the single offending parameter. When a
`*ValidationError` originates from a flag value, `Param` holds the flag
name with leading dashes (`"--priority"`, not `"priority"`). AI agents
grep this field literally to surface "the bad flag was `--X`". For
positional arguments, use the canonical name without dashes
(`"target_user_id"`).
**`Params []InvalidParam` (wire `params`)** — per-parameter validation
detail, for failures that need to report *which* parameters failed and
*why*, one entry each. Each `errs.InvalidParam` is
`{Name, Reason string, Suggestions []string}`: `Name` identifies the
parameter, `Reason` states why it failed, and the optional `Suggestions`
(wire `suggestions`, omitted when empty) carries ranked candidate
corrections an agent can retry with — the did-you-mean candidates for an
unknown flag or subcommand — without parsing the human-facing `hint`. This
is the CLI's rendering of the RFC 7807 `invalid-params` extension member
(RFC 7807 §3.1). The wire key is `params`, not `invalid_params`: the
enclosing envelope already carries `type:"validation"`, so the `invalid_`
qualifier would be redundant on the wire.
`Param` and `Params` are independent additive fields, not alternates of a
single representation. Use `Param` for the common single-parameter error;
use `Params` when one failure spans several parameters or needs a
per-parameter reason. Set with `.WithParam("--flag")` / `.WithParams(...)`.
A `params` wire example (multiple parameters each carrying a reason):
```json
{
"ok": false,
"identity": "user",
"error": {
"type": "validation",
"subtype": "invalid_argument",
"message": "2 parameters failed validation",
"params": [
{ "name": "--start", "reason": "expected RFC3339, got \"yesterday\"" },
{ "name": "--end", "reason": "must be after --start" }
]
}
}
```
### Constructing typed errors
Prefer the **builder API**. The constructor pins `Category` + `Subtype` +
@@ -378,44 +418,11 @@ them on the dynamic dispatch path where a `Problem` value is composed
once and wrapped per Category branch. Outside that pattern, new code
should reach for the builder.
Legacy helpers (`output.ErrValidation`, `output.ErrAuth`, `output.ErrNetwork`)
remain callable during migration but are `// Deprecated:` — new code goes
through the builder.
#### Shortcut `Execute` walkthrough
Adapted from `shortcuts/calendar/calendar_suggestion.go:222`, whose legacy
form is `output.ErrValidation("--duration-minutes must be between 1 and
1440")`. The typed migration target (builder form):
```go
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
duration := runtime.Int("duration-minutes")
if duration < 1 || duration > 1440 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--duration-minutes must be between 1 and 1440, got %d", duration).
WithHint("pass a value in [1, 1440]").
WithParam("--duration-minutes")
}
_, err := runtime.DoAPI(req, opts)
if err != nil {
return err // already typed by the framework boundary; propagate
}
return nil
}
```
Two patterns visible: a producer site (the typed `*errs.ValidationError`
above) and a propagation site (the `return err` after `runtime.DoAPI`,
applying [Propagate typed errors unchanged](#propagate-typed-errors-unchanged)).
When the validation logic outgrows a single range check — multiple
flags, format parsing, conditional rules — extract it into a helper that
also returns the typed `*errs.ValidationError`. The helper, not
`Execute`, sets `Param` (a helper bound to one shortcut is normal in
this codebase; see `parseTimeRange` in
`shortcuts/calendar/calendar_agenda.go:144`).
When the validation logic outgrows a single range check — multiple flags,
format parsing, conditional rules — extract it into a helper that also returns
the typed `*errs.ValidationError`; the helper, not `Execute`, sets `Param` (a
helper bound to one shortcut is normal in this codebase; see `parseTimeRange`
in `shortcuts/calendar/calendar_agenda.go`).
### Wrapping upstream errors
@@ -479,7 +486,7 @@ Rare; the existing structs cover the 9 Categories with room. If you must:
1. In `errs/types.go`, add a new section with: the struct embedding `errs.Problem`, a nil-receiver-safe `Unwrap()` if it carries `Cause`, a `NewXxxError(subtype, format, args...)` constructor, and one chained `WithX` setter per extension field.
2. Add an `IsXxx` predicate in `errs/predicates.go`.
3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_builder_test.go`.
3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_test.go`.
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
top-level wire fields are forbidden — per-Subtype data goes into the
@@ -488,19 +495,33 @@ top level.
## CI guards
| Check | Enforces | Where |
|-------|----------|-------|
| forbidigo | business path (`shortcuts/**`, `cmd/service/**`) must not call legacy `output.*` error constructors — route through the typed classifier | `.golangci.yml` |
| `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` | `lint/errscontract/` AST |
| `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code | `lint/errscontract/` AST |
| `CheckAdHocSubtype` | `ad_hoc_*` Subtypes labeled for promotion (warn) | `lint/errscontract/` AST |
| `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant or `ad_hoc_*` | `lint/errscontract/` AST |
| `CheckTypedErrorCompleteness` | every `*errs.<X>Error{Problem: errs.Problem{...}}` literal must set `Category`, `Subtype`, and `Message` | `lint/errscontract/` AST |
Two golangci-lint rules and the custom `errscontract` AST module enforce the
contract; CI runs all three on every PR.
CI runs `lint/` on every PR. Locally: `go run -C lint . ..`. The
lintcheck CLI lives in its own Go module so its `golang.org/x/tools`
dependency stays out of the shipped `lark-cli` binary's module graph;
see `lint/README.md` for how to add a new lint domain.
**golangci-lint** — scopes are defined in `.golangci.yml` (not duplicated here,
so this spec cannot drift from the lint config):
| Rule | Enforces |
|------|----------|
| forbidigo `errs-no-bare-wrap` | a command / wire-boundary final error must be typed (`errs.NewXxxError`), never a bare `fmt.Errorf` / `errors.New`; a genuine intermediate wrap opts out with `//nolint:forbidigo` + a reason |
| errorlint | every error wrap uses `%w` and every comparison uses `errors.Is` / `errors.As` — interior wraps stay legal but cannot break the `errors.Unwrap` chain the typed boundary relies on |
**errscontract** (`lint/errscontract/`, a separate Go module so its
`golang.org/x/tools` dependency stays out of the shipped binary; run locally
with `go run -C lint . ..`):
| Check | Enforces |
|-------|----------|
| `CheckNoLegacyEnvelopeLiteral` / `CheckNoLegacyCommonHelperCall` / `CheckNoLegacyRuntimeAPICall` | the removed `output.*` legacy error surface cannot be reintroduced anywhere |
| `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` |
| `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant (or `ad_hoc_*`) |
| `CheckTypedErrorCompleteness` | every typed-error struct literal sets `Category`, `Subtype`, and `Message` |
| `CheckAdHocSubtype` | `ad_hoc_*` Subtypes flagged for promotion (warning) |
| `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code |
`errscontract` also carries framework-internal invariants (nil-safe `Unwrap`,
builder immutability, unwrap symmetry); see `lint/errscontract/` for the full
set and `lint/README.md` for adding a new lint domain.
## Stability
@@ -510,67 +531,13 @@ see `lint/README.md` for how to add a new lint domain.
| Additive | new Category, new declared Subtype, new extension field on an existing struct | minor release; consumers ignore unknown fields by contract |
| Experimental | `ad_hoc_*` Subtypes; fields documented as such in `errs/types.go` | may change or be promoted/removed within one release |
The deprecated `*output.ExitError` surface is outside these tiers — it
will be removed once business migration completes.
## Migration
**Strategy shift (2026-05-26).** The original plan (`docs/design/errors-refactor/spec.md` v2.12 §9) was a centrally-driven 4-PR rollout — framework → auth domain → multi-pilot → full-repo + legacy removal. That plan is **superseded** by a hybrid model: framework owner ships framework-level hardening (including a typed `*errs.*Error` migration of `internal/**`) as one focused PR; business-domain typed migration is **self-service** via [`docs/errors-guide.md`](../docs/errors-guide.md) and the builder API, with no central sweep timeline.
Why the shift: 800+ legacy call sites split across 8+ business domains do not all share a single reviewer's bandwidth, and the contract is now expressive enough that each domain owner can migrate their own code from the guide without coordinating with framework owner.
### Current state
1. **Framework slice — ✅ shipped (PR #984).** The `errs/` typed taxonomy, classifier (`internal/errclass`), promotion stub (`internal/errcompat`, passthrough), dispatcher hook (`WriteTypedErrorEnvelope`), and the `lint/errscontract` AST guards. Wire shapes preserved byte-for-byte versus pre-PR, with **one intentional semantic fix**: config-class errors (`*core.ConfigError`) now exit `3` instead of `2`, aligning with `ExitCodeForCategory` (config errors share the auth exit slot per the taxonomy). The classifier and promote helpers are *shipped but unused* in production paths — they exist so framework migration can plug in without re-architecting.
2. **Builder API — ✅ shipped (this branch).** `errs/types.go` adds the canonical producer surface (`errs.NewXxxError(subtype, format, args...).WithX(...)`) for all 10 typed types, alongside each struct declaration. Constructor signature pins `Category` (via function name) and `Subtype` + `Message` (positional), so the producer cannot mis-specify any of the three identity fields. Optional fields chain through `.WithX(...)` setters that preserve the concrete pointer type.
### Next: framework migration PR (planned)
A single PR consolidates the work the original §9 spec split across PRs 24 — restricted to framework code, no business sweep:
- **Migrate `internal/**` typed construction to the builder API.** ~16 call sites in `internal/errclass/classify.go` (BuildAPIError fanout), `internal/auth/transport.go` (SecurityPolicy), `internal/auth/uat_client.go`, `internal/errcompat/promote*.go`, `internal/client/client.go`, `internal/client/api_errors.go`.
- **Land the framework-side semantic changes** previously scoped to spec §9 PR 2: `SecurityPolicyError` exit `1→6`, `WrapDoAPIError` typed (`*NetworkError` with subtype timeout/tls/dns/server_error/transport, `*InternalError` for JSON-decode), `WrapJSONResponseParseError` typed, `errcompat.PromoteConfigError` real Type routing, `PromoteAuthError` helper + dispatcher wiring, 10 credential Lark codes registered in codeMeta, 99991543 config classification, `resolveAccessToken` typed `*AuthenticationError`, `BuildAPIError` filling `*PermissionError.MissingScopes` / `Identity` / `ConsoleURL`, deletion of `scopeAwareChecker`.
- **Add `forbidigo` rule** banning `output.Err*` constructors in `shortcuts/**` and `cmd/**` (mirrors the contract that new business code must use the builder).
- **CHANGELOG** lists the resulting ~10 shell-exit-code shifts in one release entry (vs the spec §1 spread of 11 — the remaining one site lives in `task` business code).
### Business-domain migration (self-service, no central timeline)
Each business package migrates its own `output.Err*` call sites to the builder when convenient — typically batched within one domain. The guide at [`docs/errors-guide.md`](../docs/errors-guide.md) walks owners through the 8 typical error modes (validation / authorization / authentication / config / network / api / internal / policy) with real `file:line` examples from main. The three-layer extension model (add Subtype / add field / add Category) handles cases the existing taxonomy does not cover.
Helper assertions accept both shapes during migration (see `shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`) so domain migrations stay green incrementally.
### Legacy removal
Deferred until business migration completion approaches the asymptote. `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`, `ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and `ErrorEnvelope` are `// Deprecated:` today and stay callable. No fixed removal date.
### Before / after at a call site
```go
// before (legacy)
return output.ErrAPI(larkCode, "create event failed", resp.RawBody())
// after (typed) — cc carries Brand / AppID / Identity from the caller's context
return errclass.BuildAPIError(parsedResp, cc)
```
```go
// before (legacy validation)
return output.ErrValidation("--duration-minutes must be between 1 and 1440")
// after (builder)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--duration-minutes must be between 1 and 1440, got %d", duration).
WithParam("--duration-minutes")
```
## Troubleshooting
**Envelope shows `type=api subtype=unknown` for what should be a more
specific category.** The Lark code is unknown to `LookupCodeMeta` and fell
through to the generic bucket (`internal/errclass/classify.go`). Add the
code to `internal/errclass/codemeta_<service>.go` with the right Category
and Subtype, plus a dispatch test in `classify_test.go`.
and Subtype, plus a dispatch test in `internal/errclass/classify_test.go`.
**Envelope shows `type=internal subtype=sdk_error`.** Origin is
`client.WrapDoAPIError` taking the non-transport branch
@@ -613,8 +580,6 @@ string cannot be classified retroactively.
- *Add a new condition?* → **Add a Subtype**
- *Consume from a shell script?* → **Consumers / Shell / AI**
- *Understand or fix a CI failure?* → **CI guards**
- *Migrate a legacy `ExitError` call site?* → **Migration** + the
Deprecated note on the symbol being replaced.
- *Read source.* → `errs/doc.go``errs/category.go``errs/types.go`
`errs/predicates.go``internal/errclass/`
`cmd/root.go` `handleRootError`.

29
errs/raw.go Normal file
View File

@@ -0,0 +1,29 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import "errors"
// rawPassthrough marks an error as raw passthrough: the dispatcher must not
// rewrite its message or hint with local enrichment. Raw is
// dispatcher-internal routing state, not a wire field. It is deliberately not
// a typed taxonomy error (no embedded Problem) — it only wraps one.
type rawPassthrough struct{ err error }
func (e *rawPassthrough) Error() string { return e.err.Error() }
func (e *rawPassthrough) Unwrap() error { return e.err }
// MarkRaw wraps err as raw passthrough. MarkRaw(nil) returns nil.
func MarkRaw(err error) error {
if err == nil {
return nil
}
return &rawPassthrough{err: err}
}
// IsRaw reports whether err or any error in its chain is marked raw.
func IsRaw(err error) bool {
var raw *rawPassthrough
return errors.As(err, &raw)
}

96
errs/raw_test.go Normal file
View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs_test
import (
"encoding/json"
"errors"
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
func TestMarkRawNilReturnsNil(t *testing.T) {
if got := errs.MarkRaw(nil); got != nil {
t.Fatalf("MarkRaw(nil) = %v, want nil", got)
}
}
func TestIsRaw(t *testing.T) {
base := fmt.Errorf("boom")
if !errs.IsRaw(errs.MarkRaw(base)) {
t.Errorf("IsRaw(MarkRaw(err)) = false, want true")
}
if errs.IsRaw(base) {
t.Errorf("IsRaw(bare err) = true, want false")
}
if errs.IsRaw(nil) {
t.Errorf("IsRaw(nil) = true, want false")
}
// Raw marking survives further wrapping above it in the chain.
wrapped := fmt.Errorf("outer: %w", errs.MarkRaw(base))
if !errs.IsRaw(wrapped) {
t.Errorf("IsRaw(wrap(MarkRaw(err))) = false, want true")
}
}
func TestMarkRawPreservesErrorMessage(t *testing.T) {
base := fmt.Errorf("boom")
if got := errs.MarkRaw(base).Error(); got != "boom" {
t.Fatalf("MarkRaw(err).Error() = %q, want %q", got, "boom")
}
}
func TestMarkRawPreservesErrorsIsChain(t *testing.T) {
sentinel := errors.New("sentinel")
wrapped := fmt.Errorf("ctx: %w", sentinel)
if !errors.Is(errs.MarkRaw(wrapped), sentinel) {
t.Fatalf("errors.Is(MarkRaw(err), sentinel) = false, want true")
}
}
func TestProblemOfPunchesThroughMarkRaw(t *testing.T) {
typed := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad flag")
raw := errs.MarkRaw(typed)
p, ok := errs.ProblemOf(raw)
if !ok {
t.Fatalf("ProblemOf(MarkRaw(typed)) ok = false, want true")
}
if p.Category != errs.CategoryValidation {
t.Errorf("ProblemOf(MarkRaw(typed)).Category = %v, want %v", p.Category, errs.CategoryValidation)
}
// errors.As still finds the concrete typed error through the raw wrapper.
var ve *errs.ValidationError
if !errors.As(raw, &ve) {
t.Errorf("errors.As(MarkRaw(typed), *ValidationError) = false, want true")
}
}
// TestMarkRawUnwrapsToInnerTypedError pins the envelope-serialization
// contract: UnwrapTypedError must return the inner concrete typed error,
// not the rawPassthrough wrapper. The wrapper has no exported fields, so if it
// were returned the JSON envelope would marshal to an empty "{}" error.
func TestMarkRawUnwrapsToInnerTypedError(t *testing.T) {
base := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad flag")
typed, ok := errs.UnwrapTypedError(errs.MarkRaw(base))
if !ok {
t.Fatal("UnwrapTypedError(MarkRaw(typed)) must find a typed error")
}
out, err := json.Marshal(typed)
if err != nil {
t.Fatal(err)
}
if string(out) == "{}" {
t.Fatalf("UnwrapTypedError returned the opaque rawPassthrough wrapper; envelope would be empty: %s", out)
}
if got := errs.CategoryOf(typed); got != errs.CategoryValidation {
t.Fatalf("unwrapped category = %q, want validation", got)
}
}

View File

@@ -73,6 +73,7 @@ const (
const (
SubtypeChallengeRequired Subtype = "challenge_required" // user must complete browser challenge / MFA
SubtypeAccessDenied Subtype = "access_denied" // policy denies access outright
SubtypeContentSafety Subtype = "content_safety" // content-safety scanner blocked output in block mode
)
// CategoryInternal subtypes

View File

@@ -77,6 +77,10 @@ type ValidationError struct {
type InvalidParam struct {
Name string `json:"name"`
Reason string `json:"reason"`
// Suggestions holds machine-readable, ranked candidate corrections for this
// parameter (e.g. did-you-mean flags or subcommands), so an agent can retry
// without parsing the human-facing hint. Omitted when there are none.
Suggestions []string `json:"suggestions,omitempty"`
}
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse

View File

@@ -101,9 +101,9 @@ func TestSecurityPolicyErrorUnwrap(t *testing.T) {
// interface would panic when the root dispatcher or any caller walks the
// errors.Is / errors.Unwrap chain.
//
// The doc comments on these types claim "nil-receiver safe" but until this
// test landed nothing actually pinned that claim — exactly the
// behavioral-comment-without-test footgun caught in PR #984 review.
// The doc comments on these types claim "nil-receiver safe"; this test
// pins that claim so the behavioral comment cannot silently drift from the
// implementation.
func TestTypedErrors_UnwrapNilReceiver(t *testing.T) {
t.Helper()
checks := []struct {

View File

@@ -23,7 +23,7 @@ type ImMessageReceiveOutput struct {
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text. For interactive (cards) it stays as the raw JSON string and callers must fromjson to parse it."`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
}
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
@@ -55,8 +55,10 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
}
msg := envelope.Event.Message
content := msg.Content
if msg.MessageType != "interactive" {
var content string
if msg.MessageType == "interactive" {
content = convertlib.ConvertInteractiveEventContent(msg.Content, msg.Mentions)
} else {
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
RawContent: msg.Content,
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),

View File

@@ -7,8 +7,8 @@ import "fmt"
// AbortError is returned by a Wrapper that wants to short-circuit the
// command chain (instead of calling next). The framework converts it
// to an *output.ExitError with type "hook" so the JSON envelope carries
// the structured fields agents expect.
// to a typed errs.* error so the JSON envelope carries the structured
// fields agents expect.
//
// HookName is the framework-namespaced name ("secaudit.approval"); the
// Registrar adds the plugin-name prefix automatically.

View File

@@ -7,9 +7,9 @@ import "fmt"
// CommandDeniedError is the structured error returned by a denyStub. Every
// pruned-command execution path -- direct invocation, alias expansion,
// internal call -- returns this exact type. It is wire-compatible with the
// output.ExitError envelope via the Layer (== error.type) field and the
// detail map produced by ExitError().
// internal call -- returns this exact type. The dispatcher converts it to a
// typed errs.* error; the Layer field carries the denial layer for the
// envelope.
//
// Layer values:
//

5
harness-opt/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
# harness-opt 只入库轻量决策记录;重的原始评测 run 不进版本库(dashboard 仍读磁盘)。
baseline/runs/
**/child-runs/
verify_results/sealed-runs/
verify_results/*-runs/

View File

@@ -0,0 +1,5 @@
{
"1": 30086,
"2": 34616,
"3": 31289
}

View File

@@ -0,0 +1,50 @@
{
"k": 5,
"metrics": {
"success_rate": {
"mean": 0.4666666666666666,
"std": 0.1632993161855452,
"k": 5,
"band": [
0.14006803429557624,
0.793265299037757
]
},
"mean_score": {
"mean": 0.5111111111111111,
"std": 0.1507184440694504,
"k": 5,
"band": [
0.20967422297221028,
0.8125479992500119
]
},
"mean_context_window": {
"mean": 31997.0,
"std": 7166.8411203573105,
"k": 5,
"band": [
17663.31775928538,
46330.682240714625
]
},
"mean_duration_ms": {
"mean": 50188.86666666667,
"std": 7746.3168641619595,
"k": 5,
"band": [
34696.23293834275,
65681.50039499058
]
},
"mean_token": {
"mean": 263981.06666666665,
"std": 27890.193480385413,
"k": 5,
"band": [
208200.67970589583,
319761.45362743747
]
}
}
}

View File

@@ -0,0 +1,33 @@
{
"k": 5,
"n_cases": 3,
"effect": {
"mean": 0.5111111111111111,
"sigma": 0.1507184440694504
},
"token": {
"mean": 31997.0,
"sigma": 7166.8411203573105
},
"duration": {
"mean": 50188.86666666667,
"sigma": 7746.3168641619595
},
"phi0_per_case": {
"1": {
"effect": 0.6,
"token": 30086,
"duration": 51004
},
"2": {
"effect": 0.4,
"token": 34616,
"duration": 52787
},
"3": {
"effect": 0.5333,
"token": 31289,
"duration": 46776
}
}
}

869
harness-opt/coverage.json Normal file
View File

@@ -0,0 +1,869 @@
{
"summary": {
"total_cases": 3,
"files": 25,
"expected_declared": 0,
"blind_spots": 22,
"overfit_high": 5,
"suggest_add_cases": [
"skills/lark-im/references/lark-im-chat-identity.md",
"skills/lark-im/references/lark-im-flag-cancel.md",
"skills/lark-im/references/lark-im-flag-create.md",
"skills/lark-im/references/lark-im-message-enrichment.md",
"skills/lark-im/references/lark-im-messages-search.md"
],
"suggest_fix_routing": []
},
"files": [
{
"path": "skills/lark-im/references/lark-im-chat-identity.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "高",
"risk_lines": {
"R0": 5,
"R1": 0,
"R2": 0,
"R3": 50
},
"total_lines": 55,
"overfit_risk": "高",
"suggest_add_cases": true,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-messages-search.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "高",
"risk_lines": {
"R0": 6,
"R1": 85,
"R2": 112,
"R3": 31
},
"total_lines": 234,
"overfit_risk": "高",
"suggest_add_cases": true,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-flag-cancel.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "高",
"risk_lines": {
"R0": 6,
"R1": 25,
"R2": 21,
"R3": 15
},
"total_lines": 67,
"overfit_risk": "高",
"suggest_add_cases": true,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-flag-create.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "高",
"risk_lines": {
"R0": 7,
"R1": 25,
"R2": 20,
"R3": 15
},
"total_lines": 67,
"overfit_risk": "高",
"suggest_add_cases": true,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-message-enrichment.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "高",
"risk_lines": {
"R0": 1,
"R1": 0,
"R2": 43,
"R3": 10
},
"total_lines": 54,
"overfit_risk": "高",
"suggest_add_cases": true,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-chat-messages-list.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 5,
"R1": 90,
"R2": 40,
"R3": 22
},
"total_lines": 157,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-messages-reply.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 1,
"R1": 139,
"R2": 109,
"R3": 14
},
"total_lines": 263,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-groups.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 50,
"R1": 368,
"R2": 22,
"R3": 12
},
"total_lines": 452,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-chat-search.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 5,
"R1": 102,
"R2": 24,
"R3": 11
},
"total_lines": 142,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-chat-update.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 5,
"R1": 67,
"R2": 2,
"R3": 10
},
"total_lines": 84,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-messages-resources-download.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 5,
"R1": 55,
"R2": 24,
"R3": 10
},
"total_lines": 94,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-threads-messages-list.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 6,
"R1": 72,
"R2": 28,
"R3": 9
},
"total_lines": 115,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-chat-list.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 1,
"R1": 103,
"R2": 56,
"R3": 6
},
"total_lines": 166,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-flag-list.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 5,
"R1": 80,
"R2": 9,
"R3": 6
},
"total_lines": 100,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-reactions.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 73,
"R1": 206,
"R2": 18,
"R3": 2
},
"total_lines": 299,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-group-list-item.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 7,
"R1": 44,
"R2": 17,
"R3": 0
},
"total_lines": 68,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-group-list.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 6,
"R1": 44,
"R2": 15,
"R3": 0
},
"total_lines": 65,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-group-query-item.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 6,
"R1": 21,
"R2": 17,
"R3": 0
},
"total_lines": 44,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-shortcut-create.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 7,
"R1": 70,
"R2": 20,
"R3": 0
},
"total_lines": 97,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-shortcut-list.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 6,
"R1": 73,
"R2": 24,
"R3": 0
},
"total_lines": 103,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-feed-shortcut-remove.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "中",
"risk_lines": {
"R0": 10,
"R1": 24,
"R2": 14,
"R3": 0
},
"total_lines": 48,
"overfit_risk": "关注",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/SKILL.md",
"is_domain_skill": true,
"actual": {
"count": 3,
"pct": 1.0,
"tier": "密"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 3,
"pct": 1.0,
"tier": "密"
},
"discoverability_miss": 0,
"density_count": 3,
"density_pct": 1.0,
"density_tier": "密",
"risk_tier": "中",
"risk_lines": {
"R0": 122,
"R1": 0,
"R2": 68,
"R3": 41
},
"total_lines": 231,
"overfit_risk": "低",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-chat-create.md",
"is_domain_skill": false,
"actual": {
"count": 2,
"pct": 0.667,
"tier": "密"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 2,
"pct": 0.667,
"tier": "密"
},
"discoverability_miss": 0,
"density_count": 2,
"density_pct": 0.667,
"density_tier": "密",
"risk_tier": "中",
"risk_lines": {
"R0": 5,
"R1": 116,
"R2": 12,
"R3": 29
},
"total_lines": 162,
"overfit_risk": "低",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-messages-send.md",
"is_domain_skill": false,
"actual": {
"count": 2,
"pct": 0.667,
"tier": "密"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 2,
"pct": 0.667,
"tier": "密"
},
"discoverability_miss": 0,
"density_count": 2,
"density_pct": 0.667,
"density_tier": "密",
"risk_tier": "中",
"risk_lines": {
"R0": 1,
"R1": 140,
"R2": 109,
"R3": 14
},
"total_lines": 264,
"overfit_risk": "低",
"suggest_add_cases": false,
"suggest_fix_routing": false
},
{
"path": "skills/lark-im/references/lark-im-messages-mget.md",
"is_domain_skill": false,
"actual": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"expected": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"union": {
"count": 0,
"pct": 0.0,
"tier": "盲区"
},
"discoverability_miss": 0,
"density_count": 0,
"density_pct": 0.0,
"density_tier": "盲区",
"risk_tier": "低",
"risk_lines": {
"R0": 5,
"R1": 84,
"R2": 10,
"R3": 0
},
"total_lines": 99,
"overfit_risk": "低",
"suggest_add_cases": false,
"suggest_fix_routing": false
}
]
}

View File

@@ -0,0 +1,48 @@
{
"slug": "im-token",
"modules": [
"skills/lark-im/SKILL.md",
"skills/lark-im/references/lark-im-chat-create.md",
"skills/lark-im/references/lark-im-chat-identity.md",
"skills/lark-im/references/lark-im-chat-list.md",
"skills/lark-im/references/lark-im-chat-messages-list.md",
"skills/lark-im/references/lark-im-chat-search.md",
"skills/lark-im/references/lark-im-chat-update.md",
"skills/lark-im/references/lark-im-feed-group-list-item.md",
"skills/lark-im/references/lark-im-feed-group-list.md",
"skills/lark-im/references/lark-im-feed-group-query-item.md",
"skills/lark-im/references/lark-im-feed-groups.md",
"skills/lark-im/references/lark-im-feed-shortcut-create.md",
"skills/lark-im/references/lark-im-feed-shortcut-list.md",
"skills/lark-im/references/lark-im-feed-shortcut-remove.md",
"skills/lark-im/references/lark-im-flag-cancel.md",
"skills/lark-im/references/lark-im-flag-create.md",
"skills/lark-im/references/lark-im-flag-list.md",
"skills/lark-im/references/lark-im-message-enrichment.md",
"skills/lark-im/references/lark-im-messages-mget.md",
"skills/lark-im/references/lark-im-messages-reply.md",
"skills/lark-im/references/lark-im-messages-resources-download.md",
"skills/lark-im/references/lark-im-messages-search.md",
"skills/lark-im/references/lark-im-messages-send.md",
"skills/lark-im/references/lark-im-reactions.md",
"skills/lark-im/references/lark-im-threads-messages-list.md"
],
"modules_spec": [
"skills/lark-im/**/*.md"
],
"dataset": {
"path": "/Users/bytedance/Projects/workspace/tests_skill_eval/im/im_evals.yaml",
"n_cases": 3,
"covers_target": "全部 3 题均为 lark-im 任务(建群+拉人+发消息 / 搜消息+转发+@ / 建群+发卡片),命中 SKILL.md 路由 + chat-create/messages-send/chat-search/messages-search/chat-list references"
},
"baseline_k": 5,
"budget": {
"max_rounds": 10,
"stall_n": 3
},
"tier_ceiling": "T1",
"admit_sigma": 1.0,
"admit_sigma_duration": 1.0,
"admit_sigma_effect": 1.0,
"admit_sigma_target_boost": 0.0
}

View File

@@ -0,0 +1,60 @@
{
"task_id": "OPT-IM-1",
"title": "优化 lark-im省 token 保成功率)",
"branch": "feat/opt-im-token",
"current_phase": "round",
"phase_status": "in_progress",
"started_at": "2026-06-23T17:52:10",
"updated_at": "2026-06-23T19:38:08",
"blockers": null,
"transcript_path": "/Users/bytedance/.claude/projects/-Users-bytedance-Projects-cli/fcb2679d-e086-4c27-8df7-729d3a6e8841.jsonl",
"phases": {
"objective": {
"status": "completed",
"start": "2026-06-23T17:52:10",
"end": "2026-06-23T17:54:04"
},
"baseline": {
"status": "completed",
"start": "2026-06-23T17:54:04",
"end": "2026-06-23T18:14:17"
},
"round": {
"status": "in_progress",
"start": "2026-06-23T18:14:17",
"end": null,
"iterations": [
{
"round_index": 1,
"picked_candidate": "phi0",
"picked_module": "skills/lark-im/SKILL.md",
"tier": "T1",
"verdict": "admit",
"reason": "engine admit=score_gain(eff 0.511→0.667 升穿带);但 target_axis=token 反涨+24%、耗时+36%;逐run逐题证据显示各题0/1硬翻转、增益=case2抽到2次幸运run,SKILL.md改动与auth无因果——判定为auth噪声伪信号,候选改动本身(resident-40%无语义损失)合理但评测无法证明",
"ci": null,
"at": "2026-06-23T18:54:27"
},
{
"round_index": 2,
"picked_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
"picked_module": "skills/lark-im/references/lark-im-messages-send.md",
"tier": "T1",
"verdict": "admit",
"reason": "engine admit=score_gain(case080 单题 0.6→1.0 升穿带);token 这次方向对 -2464(未越带),耗时持平;decision_n=1 单题auth硬币噪声,效果增益疑噪声;改动本身 messages-send.md -53.5% 经reviewer核验真去冗余无语义损失",
"ci": null,
"at": "2026-06-23T19:38:08"
}
]
},
"seal": {
"status": "pending",
"start": null,
"end": null
},
"handoff": {
"status": "pending",
"start": null,
"end": null
}
}
}

13
harness-opt/opt-state.md Normal file
View File

@@ -0,0 +1,13 @@
# Opt State: OPT-IM-1 优化 lark-im省 token 保成功率)
## Phase 记录
### ✅ Phase 1: Objective
进入 baseline以现网 lark-im 文档为 Φ0K=5 重复评测立噪声地板
做了什么:确认7项objective(省token保成功率/T1/全lark-im范围/K5/10轮stall3/σ1.0)并写objective.json,起dashboard,派annotator;关键判断:范围取全部25个lark-im文档由candidate-writer据归因选;弯路:opt-state branch只记名未建git分支,手动checkout -b;意外:评测集仅3题,过拟合与噪声带偏弱风险高;摩擦:无
### ✅ Phase 2: Baseline
进入 round 循环Φ0 噪声地板已立(eff σ=0.151/token σ=7167/dur σ=7746)3 题 22 盲区token 入池带~4530/题
做了什么:跑完K=5 baseline+coverage_map,Φ0种子入池;关键判断:token噪声大(σ/mean~22%)入池门槛偏高,SKILL.md常驻是reach全集的最高杠杆;弯路:无;意外:22/25文件是盲区,reach会天然把候选限制到SKILL.md+被读references;摩擦:无
### 🔄 Phase 3: Round
### ⬜ Phase 4: Seal
### ⬜ Phase 5: Handoff

View File

@@ -0,0 +1,12 @@
{
"id": "53194d7a111df326cc078b633f43587225bd0132",
"worktree": "/Users/bytedance/Projects/cli",
"commit": "cbd6e56ac07285fd973c53ff7382da0112b6cf5d",
"phi0_worktree": "/Users/bytedance/Projects/cli",
"lineage": [
"phi0",
"a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
"557349b40feb359bb791749a37571d59edb7e72e",
"53194d7a111df326cc078b633f43587225bd0132"
]
}

View File

@@ -0,0 +1,35 @@
{
"1": {
"score": 1.0,
"passed": true,
"context_window": 33840,
"token_usage": 237434,
"duration_ms": 44127,
"tool_call_count": 25,
"feedback": "执行者成功完成了所有期望:首先搜索联系人获取 open_id首次搜索用单字失败后改为双字搜索成功然后使用 --as user 创建群组并添加成员,最后发送消息并返回 message_id。整个流程正确使用了等效的 `--as user` 身份,符合用户「使用我的身份」的要求。验证结果确认所有操作均已生效。",
"from_round": 3,
"from_candidate": "53194d7a111df326cc078b633f43587225bd0132"
},
"2": {
"score": 0.8,
"passed": true,
"context_window": 47116,
"token_usage": 612048,
"duration_ms": 114310,
"tool_call_count": 49,
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot作为 fallback避免在自动化场景中阻塞'}",
"from_round": 1,
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
},
"3": {
"score": 1.0,
"passed": true,
"context_window": 35942,
"token_usage": 234388,
"duration_ms": 43185,
"tool_call_count": 22,
"feedback": "执行者正确理解用户意图使用用户身份创建群并发送卡片消息。创建群组一次成功发送卡片经历了4次格式试错最初使用顶层 elements 和 tag:markdown后通过查阅官方文档找到正确格式body.elements + div + lark_md最终成功发送并返回 message_id。试错后自行纠正符合评判原则不构成判罚依据。\n- {'reason': '建议在 lark-im-messages-send.md 中增加飞书 interactive card 的标准格式示例,特别是 2.0 schema 下的 body.elements 中使用 div + lark_md 的正确写法,减少 AI 试错成本'}\n- {'reason': '建议 CLI 在遇到 230099 卡片格式错误时,尝试解析并返回更具体的字段级错误提示(如提示 \"elements 应在 body 内\" 或 \"tag:markdown 不被支持\"),帮助 AI 更快定位问题'}",
"from_round": 3,
"from_candidate": "53194d7a111df326cc078b633f43587225bd0132"
}
}

View File

@@ -0,0 +1,35 @@
{
"1": {
"score": 0.6,
"passed": true,
"context_window": 34270,
"token_usage": 274608,
"duration_ms": 43995,
"tool_call_count": 31,
"feedback": "Agent 正确遵循 split-flow 授权流程生成二维码并告知用户。核心任务未完成完全因用户未完成授权外部环境因素。Agent 的错误尝试scope 格式错误、绝对路径参数)均有自行纠正。整体流程符合预期,授权未完成是合理的阻塞点。\n- {'reason': '防御性设计scope 参数格式文档不明确导致 Agent 首次尝试失败。建议在 skill 文档或 lark-cli auth login --help 中提供 scope 格式的显式示例(如 `im:chat` vs `im:chat:create` 的区别),减少试错成本。'}\n- {'reason': '参数文档:`--domain` vs `--scope` 的使用场景和格式要求应更清晰。当前 Agent 用了错误的 scope 格式后才改用 domain暗示文档指引不够明确。'}\n- {'reason': '并行优化:搜索傅一铭和傅二铭可并行执行,减少等待时间。当前两次搜索串行执行。'}\n- {'reason': 'Scope 预判:创建群 + 发送消息所需 scope 应在首次授权时一次性请求,而非遇到权限错误才逐步添加。可避免多次授权流程。'}",
"from_round": 1,
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
},
"2": {
"score": 0.8,
"passed": true,
"context_window": 47116,
"token_usage": 612048,
"duration_ms": 114310,
"tool_call_count": 49,
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot作为 fallback避免在自动化场景中阻塞'}",
"from_round": 1,
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
},
"3": {
"score": 1.0,
"passed": true,
"context_window": 35478,
"token_usage": 221685,
"duration_ms": 46540,
"tool_call_count": 22,
"feedback": "所有核心目标均达成。执行者经历了两次试错shell 引号问题、@file 语法不支持但均自行修正并成功完成任务符合合理的调试流程。群创建、卡片创建、消息发送三个决策点全部通过。卡片内容准确包含「今天晚上吃什么」文字message_id 成功返回。\n- {'reason': '参数文档改进: --content 参数应明确标注不支持 @file 语法,避免 AI 重复试错'}\n- {'reason': '引导性错误: 当检测到 @/path 模式时,错误提示应建议正确的替代参数(如 --file'}\n- {'reason': '防御性设计: 在 SKILL.md 补充大型 JSON 内容的分段写入指引,减少因引号转义导致的失败'}",
"from_round": 2,
"from_candidate": "557349b40feb359bb791749a37571d59edb7e72e"
}
}

View File

@@ -0,0 +1,35 @@
{
"1": {
"score": 0.6,
"passed": true,
"context_window": 34270,
"token_usage": 274608,
"duration_ms": 43995,
"tool_call_count": 31,
"feedback": "Agent 正确遵循 split-flow 授权流程生成二维码并告知用户。核心任务未完成完全因用户未完成授权外部环境因素。Agent 的错误尝试scope 格式错误、绝对路径参数)均有自行纠正。整体流程符合预期,授权未完成是合理的阻塞点。\n- {'reason': '防御性设计scope 参数格式文档不明确导致 Agent 首次尝试失败。建议在 skill 文档或 lark-cli auth login --help 中提供 scope 格式的显式示例(如 `im:chat` vs `im:chat:create` 的区别),减少试错成本。'}\n- {'reason': '参数文档:`--domain` vs `--scope` 的使用场景和格式要求应更清晰。当前 Agent 用了错误的 scope 格式后才改用 domain暗示文档指引不够明确。'}\n- {'reason': '并行优化:搜索傅一铭和傅二铭可并行执行,减少等待时间。当前两次搜索串行执行。'}\n- {'reason': 'Scope 预判:创建群 + 发送消息所需 scope 应在首次授权时一次性请求,而非遇到权限错误才逐步添加。可避免多次授权流程。'}",
"from_round": 1,
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
},
"2": {
"score": 0.8,
"passed": true,
"context_window": 47116,
"token_usage": 612048,
"duration_ms": 114310,
"tool_call_count": 49,
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot作为 fallback避免在自动化场景中阻塞'}",
"from_round": 1,
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
},
"3": {
"score": 0.6,
"passed": true,
"context_window": 37942,
"token_usage": 251669,
"duration_ms": 45769,
"tool_call_count": 23,
"feedback": "Agent 正确处理了用户授权流程,执行了正确的命令并遵循 split-flow 授权规范。遇到用户未授权的环境问题是预期行为Agent 的处理符合文档要求。所有期望被外部环境因素阻塞,不计入失败。\n- {'reason': '考虑在 Skill 文档中明确说明对于需要用户授权的操作如果用户明确说「不需要确认」Agent 应该说明这是系统级安全约束而非可跳过的确认提示'}\n- {'reason': '在 lark-im 的群创建流程中考虑增加预检查:在发起授权前先用 --dry-run 确认操作可执行性,减少无效操作'}",
"from_round": 1,
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
}
}

View File

@@ -0,0 +1,35 @@
{
"1": {
"score": 0.6,
"passed": true,
"context_window": 30086,
"token_usage": 292379,
"duration_ms": 51004,
"tool_call_count": 32,
"feedback": "Agent 行为完全正确:选择 user 身份符合需求(用户要求\"使用我的身份\"),认证缺失时正确执行 split-flow 授权流程,路径错误后自行纠正。任务未完成源于用户未完成二维码授权(环境因素),非 agent 能力缺陷。所有期望均因 blocked_by_env 而 PASS。\n- {'reason': '**防御性设计**:在发起授权前,可先检查 `lark-cli auth status` 的 user.identity.status若为 missing 则主动告知用户\"当前用户身份未授权,我先帮你发起授权\",减少用户在看到认证错误后的困惑。'}\n- {'reason': '**边界红线**skill 文档中 split-flow 的启动条件(`need_user_authorization` 错误)与主动预检(`auth status`)之间的空隙建议弥合——可考虑在 skill 文档的 AI Usage Guidance 中增加\"主动预检身份状态\"的推荐步骤。'}\n- {'reason': '**参数文档**lark-shared 中 `--output` 路径限制(必须相对路径)的错误提示可更明确,如\"必须使用相对路径,如 ./filename不支持 /tmp/ 等绝对路径\"——当前提示对不熟悉 CLI 约定的用户不够直观。'}",
"from_round": 0,
"from_candidate": "phi0"
},
"2": {
"score": 0.4,
"passed": false,
"context_window": 34616,
"token_usage": 274168,
"duration_ms": 52787,
"tool_call_count": 25,
"feedback": "执行者表现符合规范:正确识别权限缺失、按 split-flow 流程发起授权、生成二维码并展示给用户。但用户未在执行期间完成扫码授权,导致所有核心业务目标(群聊搜索、消息筛选、转发、@通知)均未完成。这是典型的外部环境阻塞(用户交互依赖),不属于 agent 能力缺陷。执行者的错误处理和流程遵循均正确。\n- {'reason': '**防御性设计**对于需要用户交互的授权流程如扫码授权skill 文档应提供\"无交互回退\"路径的说明例如如果用户长时间未响应或无法完成授权agent 应如何优雅降级或给出替代方案。'}\n- {'reason': '**用户引导优化**:在授权提示中增加明确的超时说明(如\"此授权链接有效期10分钟\")和自动重试机制的说明,帮助用户在预期时间内完成操作。'}\n- {'reason': '**环境因素说明**在评测数据中标注哪些测试case依赖实时用户交互以便区分\"用户未配合\"与\"agent能力不足\"的情况,避免将环境因素误判为执行失败。'}",
"from_round": 0,
"from_candidate": "phi0"
},
"3": {
"score": 0.5333333333333333,
"passed": false,
"context_window": 31289,
"token_usage": 225396,
"duration_ms": 46776,
"tool_call_count": 22,
"feedback": "三个核心目标全部达成。user 身份因未授权阻断属于环境因素blocked_by_envbot 身份成功创建群并发送卡片消息。所有返回的 chat_id 和 message_id 均已验证存在。\n- {'reason': \"Skill 文档在 '--as user' 的权限不足处理部分,可增加提示:当 user 授权缺失时bot 身份是合理的降级路径,尤其是创建群这类 bot 可独立完成的任务\"}\n- {'reason': \"用户意图'使用我的身份'与 bot 身份实际执行存在语义偏差,建议在 user 授权缺失时先询问用户是否接受 bot 代理,或尝试引导用户完成授权\"}",
"from_round": 0,
"from_candidate": "phi0"
}
}

View File

@@ -0,0 +1,67 @@
[
{
"case_id": "2",
"case_label": "CLI_核心评测_015",
"verdict": "FAIL",
"token": 34616,
"duration_ms": 52787,
"tool_calls": 25,
"cmd_attempts": 5,
"cmd_failures": 3,
"cmd_fail_rate": 0.6,
"discoverability_state": "③ 读了仍失败SKILL.md reach=1.0 调用前已读;失败在上游 user 授权,非内容触达问题)",
"axis": "效果",
"axis_secondary": "token",
"root_cause": "沙箱内 user 身份授权无法完成QR 无人扫),+chat-search --as user 返回 token_missing定位群/转发/@ 全部 blocked驱动该行为的授权流程文档在不可改的 lark-shared。非 lark-im 文档根因、本轮不可修。token 侧 SKILL.md 常驻正文 5777 tok 是 T1 可控热点。",
"doc_fixable_at_T1": false,
"token_hotspot": "运行时冗余清单常驻lark-im SKILL.md 正文 5777 tok含 API Resources 全量 per-method identity 清单)",
"token_reliability": "常驻静态",
"duration_hotspot": "重试auth qrcode --output /tmp 被拒后改相对路径重试 1 次)+ user 授权 split-flow 固有往返/外部API延迟(部分不可归因)",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "SKILL.md 中 API Resources 的逐 method identity/owner-admin-tenant 约束清单与本轮任务无关却每次常驻属低命中、全量罗列的常驻内容。effect 不在 T1 可修。"
},
{
"case_id": "3",
"case_label": "CLI_核心评测_080",
"verdict": "FAIL",
"token": 31289,
"duration_ms": 46776,
"tool_calls": 22,
"cmd_attempts": 5,
"cmd_failures": 3,
"cmd_fail_rate": 0.6,
"discoverability_state": "③ 读了仍失败SKILL.md + chat-create.md + messages-send.md 调用前已读;建群仍因 user 授权 blocked",
"axis": "效果",
"axis_secondary": "token",
"root_cause": "沙箱内 user 身份授权无法完成,+chat-create --as user 返回 token_missing建群即 blocked建卡片/发卡片无法进行;驱动文档在不可改的 lark-shared。非 lark-im 文档根因、本轮不可修。本题 token 最重:读取 Skill 占 49.6%chat-create 3062 + messages-send 5367+ SKILL.md 常驻 5722。",
"doc_fixable_at_T1": false,
"token_hotspot": "按需 reference 偏大messages-send.md 5367 + chat-create.md 3062+ 运行时冗余清单常驻SKILL.md 5722messages-send.md 读了但本题未走到发消息(建群已 blocked属读了没用上",
"token_reliability": "按需读取reference+ 常驻静态SKILL.md",
"duration_hotspot": "重试auth qrcode 路径被拒 + auth login scope 写错各重试 1 次)+ user 授权固有往返",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "messages-send.md / chat-create.md 单文件偏大按需读取时仍是大块SKILL.md 常驻正文偏重。本题为 token 轴杠杆最高的题。effect 不在 T1 可修。"
},
{
"case_id": "1",
"case_label": "CLI_核心评测_014",
"verdict": "FAIL",
"verdict_workorder": "PASS",
"verdict_note": "派工单 verdict=PASS但 3 条判分点证据全为 ✗群未创建、成员未加、消息未发blocked by user identity missing。归因按判分点证据当 FAIL 处理。",
"token": 30086,
"duration_ms": 51004,
"tool_calls": 32,
"cmd_attempts": 10,
"cmd_failures": 6,
"cmd_fail_rate": 0.6,
"discoverability_state": "③ 读了仍失败SKILL.md reach=1.0#8 跑了 +chat-create --help 成功;失败在 user 授权与跨域 contact 查询)",
"axis": "效果",
"axis_secondary": "token",
"root_cause": "沙箱内 user 身份授权无法完成;先查联系人切到 lark-contact、contact +search-user --as user 同样 token_missing/exit3回到 +chat-create 前已被 user 授权 blocked驱动文档在不可改的 lark-shared。非 lark-im 文档根因、本轮不可修。token 侧 SKILL.md 常驻 5724 tok 是 T1 可控热点。",
"doc_fixable_at_T1": false,
"token_hotspot": "运行时冗余清单常驻lark-im SKILL.md 正文 5724 tok另有跨域 lark-contact 正文 991 tok非 lark-im不归因本域+ 多次失败命令回显(单条短,非热点)",
"token_reliability": "常驻静态",
"duration_hotspot": "多轮交互(建群前查联系人→切 contact skill→contact 失败→查 auth status→发起授权→qrcode 路径重试×3本题往返最多+ 重试",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "SKILL.md 常驻正文偏重失败链路user 授权 + 跨域 contact的驱动/约束文档不在 lark-im、本轮不可改。effect 不在 T1 可修。"
}
]

View File

@@ -0,0 +1,24 @@
{
"1": [
"auth login",
"auth qrcode",
"auth status",
"contact +search-user",
"contact resolve \"傅一铭\"",
"contact resolve \"傅二铭\"",
"im +chat-create"
],
"3": [
"auth login",
"auth qrcode",
"im +chat-create",
"im +messages-send"
],
"2": [
"auth login",
"auth qrcode",
"im +chat-messages-list",
"im +chat-search",
"im +messages-search"
]
}

View File

@@ -0,0 +1,29 @@
{
"1": {
"score": 0.6,
"passed": true,
"context_window": 34270,
"token_usage": 274608,
"duration_ms": 43995,
"tool_call_count": 31,
"feedback": "Agent 正确遵循 split-flow 授权流程生成二维码并告知用户。核心任务未完成完全因用户未完成授权外部环境因素。Agent 的错误尝试scope 格式错误、绝对路径参数)均有自行纠正。整体流程符合预期,授权未完成是合理的阻塞点。\n- {'reason': '防御性设计scope 参数格式文档不明确导致 Agent 首次尝试失败。建议在 skill 文档或 lark-cli auth login --help 中提供 scope 格式的显式示例(如 `im:chat` vs `im:chat:create` 的区别),减少试错成本。'}\n- {'reason': '参数文档:`--domain` vs `--scope` 的使用场景和格式要求应更清晰。当前 Agent 用了错误的 scope 格式后才改用 domain暗示文档指引不够明确。'}\n- {'reason': '并行优化:搜索傅一铭和傅二铭可并行执行,减少等待时间。当前两次搜索串行执行。'}\n- {'reason': 'Scope 预判:创建群 + 发送消息所需 scope 应在首次授权时一次性请求,而非遇到权限错误才逐步添加。可避免多次授权流程。'}"
},
"2": {
"score": 0.8,
"passed": true,
"context_window": 47116,
"token_usage": 612048,
"duration_ms": 114310,
"tool_call_count": 49,
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot作为 fallback避免在自动化场景中阻塞'}"
},
"3": {
"score": 0.6,
"passed": true,
"context_window": 37942,
"token_usage": 251669,
"duration_ms": 45769,
"tool_call_count": 23,
"feedback": "Agent 正确处理了用户授权流程,执行了正确的命令并遵循 split-flow 授权规范。遇到用户未授权的环境问题是预期行为Agent 的处理符合文档要求。所有期望被外部环境因素阻塞,不计入失败。\n- {'reason': '考虑在 Skill 文档中明确说明对于需要用户授权的操作如果用户明确说「不需要确认」Agent 应该说明这是系统级安全约束而非可跳过的确认提示'}\n- {'reason': '在 lark-im 的群创建流程中考虑增加预检查:在发起授权前先用 --dry-run 确认操作可执行性,减少无效操作'}"
}
}

View File

@@ -0,0 +1,97 @@
# Round 1 归因(候选模块见 candidate_modules模块由 candidate-writer 根据诊断和 reach 选定)
> 目标objective.json**在不回退成功率的前提下降低 lark-im skill 文档的 token 成本**。effect 是硬门槛、不可退化token 与 duration 是并列成本杆。tier=T1仅可改 `skills/lark-im/**`。
> 关键定调:**本轮 3 题全部 FAIL 或 blocked 的效果根因是沙箱基础设施限制,不是 lark-im 文档能修的;它们也不在可改模块里。** 因此本轮的真实抓手是 token 轴(每次运行常驻 + 误导性内容),不是去「修挂题」。下面分维度说明。
## 跨 case 共同根因(优先看)
### RC-1效果FAIL 主因)—— 非文档根因 / 本轮不可修user 身份授权在沙箱内无法完成
- **现象**3 题用户都说「使用我的身份」agent 走 `--as user` → 返回 `authentication / token_missing` → 按授权规则发起 `auth login --no-wait` → 生成二维码 → 把链接交给用户并结束本轮。沙箱里没有真人扫码user 身份永远 `missing`,于是建群/搜群/发消息全部 blocked。三题轨迹高度同构015/080/014
- **行为是被文档「正确」驱动的,不是 agent 乱来**:发起 split-flow 授权、生成二维码、展示链接后交还控制权,这一整套是 `skills/lark-shared/SKILL.md`L17、L72105明确 MUST 的流程。agent 严格照做。
- **归因落点**:根因在**沙箱无法完成交互式 user 授权**(基础设施)+ 驱动该行为的授权流程文档在 `lark-shared`
- **为什么本轮不可修(重要,给 candidate-writer 的边界)**
1. `lark-shared/SKILL.md` **不在 candidate_modules**objective.modules 只含 `skills/lark-im/**`),无权改。
2. 即便能改,沙箱不能扫码这一物理限制不是文档能绕过的——这是环境,不是内容缺失。
3. **不要试图通过让 agent 改走 `--as bot` 来「修绿」**用户显式要「我的身份」grader 判分点也写「使用当前用户身份创建」。改路由去 bot 是 reward-hack绕过判分点、语义回退不是合法的成功率修复。reviewer 会据此 FAIL。
- **axis=效果**,但标注为**无文档根因 / 本轮不改**。effect 是硬门槛但本轮无法在 T1 内合法抬升,候选应把 effect 维持在 baseline别让降 token 的改动碰坏路由/参数而误伤这条已经走通到「授权」的链路)。
### RC-2token本轮真正的抓手—— 每次运行常驻的 lark-im 注入正文偏重
- **现象**:每题固定加载两块 lark-im 正文,且**与该题任务大多无关**
- `lark-im`**Skill 列表注入**(系统级 description 段4,612 tok015 占 28.2%、080 占 18.8%、014 占 25.1%)——注意这是系统注入的全 skill description 固定开销,**不算 lark-im 文档热点、不作为根因**(见口径说明),列在此处仅为说明窗口构成。
- `lark-im`**SKILL.md 正文**(经 Skill 工具加载reach=1.0):约 **5,7225,777 tok/题**,三题都常驻。这是 `skills/lark-im/SKILL.md`**在可改模块内,是 token 轴的头号可控热点**。
- **SKILL.md 里有大量与本轮任务无关的常驻清单**`## API Resources`L114+)逐条列了 chats / chat.members / messages / reactions / threads / image / pin / feed 等**每个 resource.method 的 identity 规则与 owner/admin/tenant 约束**L123190几十行。本轮 3 题只用到建群、搜群/搜消息、发消息、转发、@——绝大多数 method 行每次运行都被加载却从不被用到。这是典型「每次运行都会加载的运行时冗余清单常驻」。
- **可信度=常驻静态**SKILL.md 经 Skill 工具每题必加载reach=1.0tiktoken 可测、跨题稳定5,722/5,724/5,777 三题一致)。这是降 token 最稳的发力点。
- **axis=token**。文档位置:`skills/lark-im/SKILL.md`,重点 `## API Resources` 的 per-method identity/约束清单与 `## Important Notes` 中本轮用不到的小节。
### RC-3token次级抓手—— 按需 reference 体积偏大,且只在用到的题里计入
- **现象**080 读了 `chat-create.md`(3,062 tok) + `messages-send.md`(5,367 tok),两块 reference 合计 8,429 tok占该题 visible 的 34.4%。014 也读了 chat-create.md。
- **判据**reachchat-create=0.667、messages-send=0.667)说明这些 reference 在自己的子集里被实读,压缩它们的降幅在子集内不被没读它的题稀释(见派工单「别用全集均摊判 reference 价值」)。`messages-send.md` 单文件 5,367 tok 尤其大。
- **可信度=按需读取**:只在实际 Read 该 reference 的题里计入,不能按全集均摊。
- **axis=token**。文档位置:`skills/lark-im/references/lark-im-messages-send.md``lark-im-chat-create.md`
### RC-4duration弱信号需复现—— `auth qrcode --output "/tmp/..."` 被拒后反应式重试
- **现象**3 题都先用 `--output "/tmp/lark_auth_qr.png"`(或 `/workspace/agent-cwd/qrcode.png`)→ 报 `validation / invalid_argument: unsafe output path` → 改用相对路径 `./xxx.png` 重试成功。每题多 12 个往返。
- **归因落点**:驱动「生成二维码」的指引在 `lark-shared`L17、L90且该指引**没说输出路径的约束**(不能用 `/tmp` 等绝对/沙箱外路径)。这是「报错没指下一步 + 文档没写约束」的耗时根因。
- **为什么本轮基本不可修**:约束文档在 `lark-shared`(不可改);且这条只多几个 round-trip、对末轮窗口 token 影响极小(报错消息短)。
- **可信度**:耗时波动大,单题不算数;但此模式**3 题一致复现**,作为 duration 旁证可信度提升。不过它仍**不在 T1 可改范围**,仅记录。
- **axis=duration**,标注为**驱动文档不可改lark-shared**。
## 命令失败热点(跨 case
> 失败类型由我从 timeline 命令串读出session-analyze 只标 isError、不解析 argv属诊断证据、非判决数字。
| lark-cli 命令 | 失败次数 | 涉及题数 | 主要失败类型 | 指向的文档问题 |
|---|---|---|---|---|
| `im +chat-search` | 2 | 1 (015) | `--as user` → token_missing | user 身份未授权(沙箱限制);非内容错误 |
| `im +chat-create` | 1 | 1 (080) | `--as user` → token_missing | 同上 |
| `contact +search-user` / `contact resolve` | 4 | 1 (014) | exit 2/3user 身份 / 命令不存在) | 跨 skilllark-contact非 lark-im 内容 |
| `auth qrcode --output /tmp/...` | 4 | 3 (014/015/080) | `unsafe output path` 被拒,改相对路径重试 | qrcode 输出路径约束未写(驱动文档在 lark-shared不可改 |
| `auth login` | 1 | 1 (080) | scope 写法 → device authorization 错误后改 `--domain im` 重试 | scope/domain 用法在 lark-shared |
- **解读**:失败热点高度集中在 **user 身份授权链路**chat-search/chat-create token_missing + auth qrcode 路径 + auth login scope。这一整条链路的驱动与约束文档都在 `lark-shared`**不是 lark-im 文档能修的**。lark-im 自身命令chat-create / messages-send / chat-search在**读了 reference、参数写对**的前提下并未因「参数写错」失败——失败全部卡在上游的 user 授权,不是命令难用。**这意味着没有 lark-im 侧的「报错/输出整形」工单**。
## 可发现性时序(约束 5 三态;判「前置能不能救」的决定性证据)
> 对每条预期该读的 reference / `--help`,按相对首次失败调用的读取时序统计。`--help` 扫 Bash不在 reach 里)。
| reference / `--help` | 聚合 reach | ①从没读 | ②失败后才读 | ③读了仍错 | 主导态 → 改动方向 |
|---|---|---|---|---|---|
| `lark-shared/SKILL.md` | 1.0 | 0 | 0 | 3 | ③ 调用前已读,仍卡授权 → **非触达问题**;且不可改 |
| `lark-im-chat-create.md` | 0.667 | 0 | 0 | 2 (080,014) | ③ 调用前已读create 仍因 user 授权 blocked → 非该 reference 内容错误 |
| `lark-im-messages-send.md` | 0.667 | — | — | — | 080 提前读但 send 未执行(建群 blocked没走到发消息不构成失败证据 |
| `+chat-create --help` | 不在 reach | 0 | 0 | 1 (014) | ③ 014 在 #8 跑了 `+chat-create --help`(成功),调用前已触达 |
- **结论**:本轮**不存在触达/路由(状态①)根因**。三题都在调用前读到了 SKILL.mdreach=1.0)、读到了相关 reference、甚至跑了 `--help`。失败发生在**内容已触达之后的上游授权环节(状态③语义,但根因是环境而非文档内容错)**。
- **对 candidate-writer 的含义****不要把 RC-1 误判为①而推「前置授权说明」**——内容已经读到了,前置救不了沙箱不能扫码。前置类改动在本轮对 effect 无效,只会增 token与目标背道而驰。
## 差距台账复盘
-round 1`discard-ledger.json` 为空)。
## 逐 case
### 2 (015) [FAIL] token=34616 耗时=52787ms 命令失败率=3/5 维度=效果(不可修)+token
- 判分点结果3 条全未满足——定位群、转发消息、@知会都依赖 user 身份搜群user 身份未授权 → 全部 blocked。
- 命令失败3/5。2× `+chat-search --as user` → token_missing1× `auth qrcode --output /tmp` → unsafe output path改相对路径成功
- 可发现性时序SKILL.md 调用前已读reach=1.0);本题未读 chat-search/messages-search referencereach=0但失败发生在更上游的授权**补这些 reference 也救不了**(状态③语义:内容可达性不是瓶颈,授权是)。
- token 归因SKILL.md 正文 5,777 tok常驻静态35.3%+ 系统级 Skill 列表注入 4,612 tok固定开销不归因。本题未读大 reference故 token 主来源就是常驻 SKILL.md 正文。
- 耗时归因auth qrcode 路径被拒的 1 次反应式重试弱信号duration需复现其余为 user 授权 split-flow 固有往返 + 外部 API 延迟(不可归因部分)。
- 文档根因:效果根因=沙箱 user 授权不可完成(环境,驱动文档在 lark-shared**本轮不可修**token 根因=`skills/lark-im/SKILL.md` 常驻正文偏重(**可修T1 抓手**)。
### 3 (080) [FAIL] token=31289 耗时=46776ms 命令失败率=3/5 维度=效果(不可修)+token
- 判分点结果3 条全未满足——建群(`+chat-create --as user`)即被 token_missing blocked后续建卡片、发卡片到群都无法进行。
- 命令失败3/5。1× `+chat-create --as user` token_missing1× `auth login --scope "..."` device authorization 错误(改 `--domain im` 重试1× `auth qrcode --output /tmp` unsafe path改相对路径成功
- 可发现性时序:调用前读了 SKILL.md + chat-create.md + messages-send.md全部状态③调用前已触达建群仍因 user 授权 blocked**非 reference 内容错误**。
- token 归因:**本题 token 最重,读取 Skill 占 49.6%**——chat-create.md 3,062 + messages-send.md 5,367 = 8,429 tok按需读取 SKILL.md 正文 5,722 tok常驻静态。这是 RC-2 + RC-3 同时发力的题。messages-send.md 提前读但本题根本没走到发消息(建群已 blocked属「读了没用上」的浪费。
- 耗时归因auth qrcode 重试 + auth login scope 写错重试,各 1 次反应式往返弱信号duration需复现
- 文档根因:效果=沙箱 user 授权不可修token=SKILL.md 常驻正文 + 两个偏大 reference**可修T1 抓手;本题杠杆最高**)。
### 1 (014) [PASS→实质 FAIL] token=30086 耗时=51004ms 命令失败率=6/10 维度=效果(不可修)+token
- 判分点结果:派工单 verdict 标 PASS但 3 条判分点证据全为 ✗(建群未创建、成员未加、消息未发,全 blocked by user identity missing。**实质是 FAIL**PASS 系上层聚合口径差异,归因按判分点证据处理。
- 命令失败6/10最高`contact resolve` ×2 exit 2命令形态不对走的是 lark-contact 域);`contact +search-user --as user` ×2 exit 3user 未授权);`auth qrcode --output 绝对路径` ×2 unsafe path第三次相对路径成功
- 可发现性时序:#7 调用前读 SKILL.mdreach=1.0#8 跑了 `+chat-create --help`(成功,状态③,调用前已触达建群用法);随后为查联系人切到 lark-contact skill。失败集中在 user 授权与跨域 contact 查询,**非 lark-im 内容可达性问题**。
- token 归因SKILL.md 正文 5,724 tok常驻静态31.1%+ 系统 Skill 列表注入 4,612 tok固定开销不归因+ lark-contact 正文 991 tok跨域非 lark-im。lark-cli 命令累计 2,577 tok14%),含多次失败回显,但单条都短、非热点。
- 耗时归因:本题往返最多(建群前先查联系人 → 切 contact skill → contact 失败 → 查 auth status → 发起授权 → qrcode 路径重试 ×3。多为 user 授权链路 + 跨域查联系人固有串行 + 反应式重试duration 弱信号,需复现)。
- 文档根因:效果=沙箱 user 授权 + 跨域 contact 不可用环境不可修token=`skills/lark-im/SKILL.md` 常驻正文(**可修T1 抓手**)。
## 给 candidate-writer 的收口(不含具体改法)
- **唯一在 T1 内可合法发力的轴是 token**,对应 RC-2SKILL.md 常驻正文3 题全命中、最稳)与 RC-3chat-create/messages-send reference 偏大080 命中)。两者方向一致(减体积),可作为本轮候选的目标轴。
- **effect 不可在本轮 T1 内合法抬升**RC-1 环境限制 + 驱动文档在不可改的 lark-shared。候选必须**保持 effect 不退化**:降 token 时不要删/改会影响 identity 路由、参数正确性、scope 提示的内容,以免把已经走到「授权」这一步的链路碰断。
- **方向冲突提示**RC-1 若有人想「补授权说明帮 agent 过」与目标(降 token方向相反且对沙箱无效——**明确不要做**。RC-2/RC-3减体积与目标同向无冲突。
- **缺失信息doc_fix_hint 语气,非药方)**SKILL.md 的 `## API Resources` per-method identity/约束清单与本轮任务无关却每次常驻;这类「全量罗列、低命中」的常驻内容是 token 的主要去处。messages-send.md / chat-create.md 单文件偏大,按需读取时仍是大块。
- **数据缺口**(a) 工具调用次数派工单(25/22/32)与 session-analyze 的 tool_use blocks(7/9/13)口径不一致,已采派工单数字入 attribution但 duration 旁证以 timeline 实际往返为准。(b) duration 根因RC-4单轮不足以定论需多轮/多次复现;且其驱动文档在 lark-shared 不可改。(c) 014 派工单 verdict=PASS 与判分点证据全 ✗ 冲突,归因按判分点证据当 FAIL 处理。

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,222 @@
{
"skills/lark-im/SKILL.md": {
"reach": 1.0,
"read_cases": [
"1",
"2",
"3"
],
"actual_cases": [
"1",
"2",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": true
},
"skills/lark-im/references/lark-im-chat-create.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-identity.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-update.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-groups.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-cancel.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-message-enrichment.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-mget.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-reply.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-resources-download.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-send.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-reactions.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-threads-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
}
}

View File

@@ -0,0 +1,15 @@
{
"generated_by": "lark-cli-harness:opt-reviewer",
"verdict": "PASS",
"module": "skills/lark-im/SKILL.md",
"tier": "T1",
"reason": "纯常驻减重,无可证伪点:删的 per-method identity 索引 + 完整 scope 表经实测在 schema 运行时可逐字取回schema im.chats.create 返回与被删文本相同的 Identity 串、schema._meta.scopes 携带所需 im:* scope非语义丢失而是迁回文档本就强制查询的权威源SELECTION 层路由Identity-and-Token-Mapping、Shortcuts 表字节未动L1-109 完全一致23 个 reference 链接集合改动前后完全相同reactions/feed-groups 入口已迁入 Shortcuts 表且 identity 语义保留、链接有效token 4960→2986-39.8%tiktoken cl100k_base 实测吻合声明)为真删非搬运;只服务 RC-2 一个根因。试图证伪四维均找不到证据。",
"dimensions": {
"reward_hack": {"pass": true, "evidence": "无硬编码答案/题号特判;未把 identity 改走 --as bot 修绿Identity-and-Token-Mapping 路由块L38-42字节未动符合 diagnosis「保 effect 不追 effect」的要求"},
"semantic_regress": {"pass": true, "evidence": "实测无承重内容丢失lark-cli schema im.chats.create 逐字返回被删的 Identity 串、schema._meta.scopes 携带所需 scope如 im:message.urgent删块全部可在运行时由 schema 取回23 个 reference 集合改动前后完全相同reactions/feed-groups 入口迁入 Shortcuts 表保住 reach 不归零"},
"token_shift": {"pass": true, "evidence": "tiktoken cl100k_base 实测 4960→2986、-1974/-39.8% 与声明吻合;是 reach=1.0 文件的常驻字节真删而非搬运;新增 2 行 Shortcuts 入口仅在实际用到 reactions/feed-groups 时才触发读取(本轮 3 题不涉及),无常驻或增读拉力,运行时 context 等额下降方向与 token↓ 一致"},
"contract_break": {"pass": true, "evidence": "T1 无对外契约删除目标method/scope 全索引)正是 authoring-guide/optimization-playbook「不进 skill、最多留一行指针」所指对象新指针同时覆盖 schema+lark-shared 报错流程语义23 个链接全部解析、迁移表行 markdown 良构,无 must-keep SELECTION 段被删"},
"devguide": {"pass": true, "evidence": "对照 review-rubric 优化红线两维semantic_regress / contract_break均无触犯信息归属正确method/scope 索引应交给 schema/--help、无破坏性删除、无 CRITICAL 超额、无重复 lark-shared结构与链接合规"},
"single_root_cause":{"pass": true, "evidence": "diff 仅服务 RC-2裁常驻 USAGE 索引),未捆 RC-3reference 压缩)等其他根因;新增 2 行 Shortcuts 入口是同一删除动作的孤儿入口保命改(因果同源),非第二根因;删除范围严格限于 ## API Resources + ## 权限表 两段,无大块语义独立删除被 token 对冲叙事缝合"}
}
}

View File

@@ -0,0 +1,404 @@
{
"round": 1,
"status": "admitted",
"parent_id": "phi0",
"parent_worktree": "/Users/bytedance/Projects/cli",
"child_worktree": "/Users/bytedance/Projects/cli",
"base_commit": "040ef17eae0ac350c556081544793aacce675e90",
"module": "skills/lark-im/SKILL.md",
"candidate_modules": [
"skills/lark-im/SKILL.md",
"skills/lark-im/references/lark-im-chat-create.md",
"skills/lark-im/references/lark-im-chat-identity.md",
"skills/lark-im/references/lark-im-chat-list.md",
"skills/lark-im/references/lark-im-chat-messages-list.md",
"skills/lark-im/references/lark-im-chat-search.md",
"skills/lark-im/references/lark-im-chat-update.md",
"skills/lark-im/references/lark-im-feed-group-list-item.md",
"skills/lark-im/references/lark-im-feed-group-list.md",
"skills/lark-im/references/lark-im-feed-group-query-item.md",
"skills/lark-im/references/lark-im-feed-groups.md",
"skills/lark-im/references/lark-im-feed-shortcut-create.md",
"skills/lark-im/references/lark-im-feed-shortcut-list.md",
"skills/lark-im/references/lark-im-feed-shortcut-remove.md",
"skills/lark-im/references/lark-im-flag-cancel.md",
"skills/lark-im/references/lark-im-flag-create.md",
"skills/lark-im/references/lark-im-flag-list.md",
"skills/lark-im/references/lark-im-message-enrichment.md",
"skills/lark-im/references/lark-im-messages-mget.md",
"skills/lark-im/references/lark-im-messages-reply.md",
"skills/lark-im/references/lark-im-messages-resources-download.md",
"skills/lark-im/references/lark-im-messages-search.md",
"skills/lark-im/references/lark-im-messages-send.md",
"skills/lark-im/references/lark-im-reactions.md",
"skills/lark-im/references/lark-im-threads-messages-list.md"
],
"module_reach": {
"skills/lark-im/SKILL.md": {
"reach": 1.0,
"read_cases": [
"1",
"2",
"3"
],
"actual_cases": [
"1",
"2",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": true
},
"skills/lark-im/references/lark-im-chat-create.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-identity.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-update.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-groups.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-cancel.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-message-enrichment.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-mget.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-reply.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-resources-download.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-send.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-reactions.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-threads-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
}
},
"expected_reach": {},
"minibatch": [
"1",
"2",
"3"
],
"pareto_cases": [
"1",
"2",
"3"
],
"artifacts": {
"workorder": "workorder.md",
"diagnosis": "diagnosis.md",
"attribution": "attribution.json",
"strategy": "strategy.md",
"review": "review.json",
"trend": "trend.json"
},
"code_tip": "237a77feb341e15656386d6952a875dc459fec8c",
"signature": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
"tier": "T1",
"intent": "将 SKILL.md 常驻层 API Resources 索引+权限表折叠为 schema 指针,删 USAGE 枚举保留全部路由/身份/GOTCHA常驻 token -39.8%",
"target_axis": "token",
"changed_files": [
"skills/lark-im/SKILL.md"
],
"decision_basis": {
"type": "module",
"module": "skills/lark-im/SKILL.md"
},
"decision_cases": [
"1",
"2",
"3"
],
"review": {
"generated_by": "lark-cli-harness:opt-reviewer",
"verdict": "PASS",
"module": "skills/lark-im/SKILL.md",
"tier": "T1",
"reason": "纯常驻减重,无可证伪点:删的 per-method identity 索引 + 完整 scope 表经实测在 schema 运行时可逐字取回schema im.chats.create 返回与被删文本相同的 Identity 串、schema._meta.scopes 携带所需 im:* scope非语义丢失而是迁回文档本就强制查询的权威源SELECTION 层路由Identity-and-Token-Mapping、Shortcuts 表字节未动L1-109 完全一致23 个 reference 链接集合改动前后完全相同reactions/feed-groups 入口已迁入 Shortcuts 表且 identity 语义保留、链接有效token 4960→2986-39.8%tiktoken cl100k_base 实测吻合声明)为真删非搬运;只服务 RC-2 一个根因。试图证伪四维均找不到证据。",
"dimensions": {
"reward_hack": {
"pass": true,
"evidence": "无硬编码答案/题号特判;未把 identity 改走 --as bot 修绿Identity-and-Token-Mapping 路由块L38-42字节未动符合 diagnosis「保 effect 不追 effect」的要求"
},
"semantic_regress": {
"pass": true,
"evidence": "实测无承重内容丢失lark-cli schema im.chats.create 逐字返回被删的 Identity 串、schema._meta.scopes 携带所需 scope如 im:message.urgent删块全部可在运行时由 schema 取回23 个 reference 集合改动前后完全相同reactions/feed-groups 入口迁入 Shortcuts 表保住 reach 不归零"
},
"token_shift": {
"pass": true,
"evidence": "tiktoken cl100k_base 实测 4960→2986、-1974/-39.8% 与声明吻合;是 reach=1.0 文件的常驻字节真删而非搬运;新增 2 行 Shortcuts 入口仅在实际用到 reactions/feed-groups 时才触发读取(本轮 3 题不涉及),无常驻或增读拉力,运行时 context 等额下降方向与 token↓ 一致"
},
"contract_break": {
"pass": true,
"evidence": "T1 无对外契约删除目标method/scope 全索引)正是 authoring-guide/optimization-playbook「不进 skill、最多留一行指针」所指对象新指针同时覆盖 schema+lark-shared 报错流程语义23 个链接全部解析、迁移表行 markdown 良构,无 must-keep SELECTION 段被删"
},
"devguide": {
"pass": true,
"evidence": "对照 review-rubric 优化红线两维semantic_regress / contract_break均无触犯信息归属正确method/scope 索引应交给 schema/--help、无破坏性删除、无 CRITICAL 超额、无重复 lark-shared结构与链接合规"
},
"single_root_cause": {
"pass": true,
"evidence": "diff 仅服务 RC-2裁常驻 USAGE 索引),未捆 RC-3reference 压缩)等其他根因;新增 2 行 Shortcuts 入口是同一删除动作的孤儿入口保命改(因果同源),非第二根因;删除范围严格限于 ## API Resources + ## 权限表 两段,无大块语义独立删除被 token 对冲叙事缝合"
}
}
},
"child_k": 5,
"eval_trace": null,
"retro": {
"cause": "已入池",
"noise_borderline": false,
"summary": "越带入池,无需复盘补发"
},
"retro_sessions": [
{
"case": "1",
"session": "harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl",
"axis": "token",
"expect": "降",
"parent": 30086,
"child": 34270,
"gain": "反向",
"pass_delta": null
},
{
"case": "2",
"session": "harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_015/0/session.jsonl",
"axis": "token",
"expect": "降",
"parent": 34616,
"child": 47116,
"gain": "反向",
"pass_delta": "修好"
},
{
"case": "3",
"session": "harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_080/0/session.jsonl",
"axis": "token",
"expect": "降",
"parent": 31289,
"child": 37942,
"gain": "反向",
"pass_delta": "修好"
}
],
"verdict": "admitted",
"ci": null,
"new_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
"decision": {
"parent_success": 0.3333333333333333,
"child_success": 1.0,
"parent_score": 0.5111111111111111,
"child_score": 0.6666666666666666,
"score_saved": 0.15555555555555556,
"score_threshold": 0.09532271373123208,
"parent_token": 31997.0,
"child_token": 39776.0,
"saved": -7779.0,
"threshold": 4532.708313776408,
"parent_duration": 50189.0,
"child_duration": 68024.66666666667,
"dur_saved": -17835.66666666667,
"dur_threshold": 4899.200953624988,
"dur_margin": 1.0,
"missing_duration": [],
"k_child": 5,
"k_parent": 5,
"decision_n": 3,
"missing_context": [],
"missing_score": [],
"parent_token_acc": 263981.0,
"child_token_acc": 379441.6666666667,
"phi0_score": 0.5111111111111111,
"eff_margin": 1.0,
"parent_token_full": 31997.0,
"child_token_full": 39776.0,
"saved_full": -7779.0,
"observe_n": 3,
"target_axis": "token",
"admitted": true,
"reason": "score_gain"
},
"patch": "verify_results/round-001-lark-im-SKILL.patch"
}

View File

@@ -0,0 +1,44 @@
# Round 1 候选策略(模块=skills/lark-im/SKILL.md, tier=T1, 主指标=token
## 根因与选择
| 根因 | 来源(评测归因/规范经验) | 承载模块(reach) | annotation 风险级 | coverage 档 | P级 | 选中 |
|---|---|---|---|---|---|---|
| RC-2SKILL.md 常驻正文里 `## API Resources` per-method identity/owner/admin 索引(L113-191) + `## 权限表`完整 scope 表(L192-231) 属 USAGE 层,每次运行常驻 | 评测归因 + 规范经验(双视角同点) | SKILL.md(1.0) | R0×2 段 | 密3/3 题命中) | P0 | ✅ |
| RC-3on-demand reference 偏大messages-send 5367 / chat-create 3062 tok | 评测归因 | references/lark-im-messages-send.md(0.667)、chat-create.md(0.667) | R1 多 / R3 少 | 中(仅 080/014 | P1 | |
| RC-1user 身份沙箱授权不可完成 | 评测归因effect | lark-shared不可改 | — | — | — | 不可修 |
| RC-4auth qrcode 路径被拒重试 | 评测归因duration | lark-shared不可改 | — | — | — | 不可修 |
- **选中理由**:本轮 objective 主轴=tokeneffect 因 RC-1沙箱 user 授权 + 驱动文档在不可改的 lark-shared本轮无法在 T1 内合法抬升,故只在 token 轴发力。RC-2 是 reach=1.0 的头号可控热点——3 题全命中、tiktoken 稳定5,722/5,724/5,777、每次运行都付费。RC-3 是 reach=0.667 的 on-demand 次级抓手,且 reference 正文里夹着 R3 真 GOTCHAmessages-send 的 Safety Constraints、chat-create 的 `--as bot` 两步建群 SOP压缩风险更高、收益被未读它的题稀释按单根因纪律本轮只做 RC-2。RC-1/RC-4 落 lark-shared越界即被 scope check 拒,且沙箱物理限制非文档可绕——不碰。
- **选模块理由**SKILL.md reach=1.0(经 Skill 工具每题必加载),是 RC-2 的唯一承载。改动全部落在它内部coherent不触任何别的 skill。
- **规范经验源补注**:双视角在同一处汇合——
- 视角②annotation`skill-annotations.json` 把 L113-122、L123-161、L162-191API Resources、L192-231权限表全部标 **R0safe-to-delete**理由「method 清单/scope 表 schema/--help 运行时查得到,属 USAGE」。
- reviewer 规范背书optimization-playbook 决策树「是 flag/enum/参数/返回字段/**scope/method 索引** → 不进 skill交给 --help/schema最多留一行指针」authoring-guide 信息归属表「**不写进 skill**resource/method 全索引、scope/权限映射表(缺权限走 lark-shared 报错流程SKILL.md 锚点 6「`--help`/schema 管 USAGEreference 只留 gotcha」。三处独立指向同一删除对象。
- coverage3/3 题都加载 SKILL.mdtoken 收益在常驻层可被当轮 eval 直接裁(静态 tiktoken + 每题 visible 构成),不是难裁的拟合型改动。
## 改了什么(逐处)
- `skills/lark-im/SKILL.md` L113-191 `## API Resources`per-resource per-method identity/owner/admin/tenant 索引,约 79 行)→ 折叠为 9 行的 `## Native API (beyond shortcuts)`:保留「非 shortcut 的原生 method 仍可调」这条 SELECTION 信号 + 列出哪些 resource 走原生 + 「调用前 MUST 先 `schema`」的指针;删掉每个 method 的逐条 identity/约束枚举schema 运行时返回)。
- `skills/lark-im/SKILL.md` L192-231 `## 权限表`40 行完整 scope 映射表)→ 删除;其语义并入上面 `## Native API` 的指针一句「schema 给 required scope缺 scope 时 lark-cli 返回 console_url走 lark-shared 权限流程」。
- `skills/lark-im/SKILL.md` Shortcuts 速查表新增 2 行:`reactions.*``references/lark-im-reactions.md``feed.groups.*``references/lark-im-feed-groups.md`。**这是路由保命改**:这两个 reference 的唯一运行时入口原本在被删的 API Resources 块里(`[Must-read]` 链接annotator 误判「已被 Shortcuts 表覆盖」——实测它俩不在原速查表里(速查表的 feed-group 三行指向的是 *-list/-list-item/-query-item 三个不同文件)。不补这 2 行 = 删 reference 链接 = 该 reference reach 永久归 0、路由断裂。
## 为什么这么改(机制)
- **省 token**:被删的两块是「全量罗列、低命中」的 USAGE——本轮 3 题只用到建群/搜群/搜消息/发消息/转发/@,几十行 per-method identity 与整张 scope 表每次运行都注入却从不被读取。删后 Agent 仍能:(1) 经 SKILL.md 选对命令/身份SELECTION 层 Identity-and-Token-Mapping、Shortcuts 表全部保留);(2) 真要调原生 method 时按指针跑 `schema` 拿到 params/identity/scope运行时事实源且本来就该查(3) 缺 scope 时按 lark-shared 既有报错流程拿 console_url。即「删了 Agent 还做得对吗?做得对就删」(锚点 2
- **不碰 effect**:保留全部 SELECTION 层路由——CRITICAL 先读 lark-sharedL13、Identity and Token Mappinguser/bot↔tokenR3、完整 Shortcuts 速查表、各域特有 GOTCHAbot 取不到 sender name、enrichment/download 契约、flag/feed-shortcut 概念)。没有改 identity 路由、没有改参数正确性、没有删 scope 提示语义(指针仍指向 schema+lark-shared 流程。已经走到「user 授权」这一步的链路不会被碰断。
- **规范背书**optimization-playbook §2 决策树 + authoring-guide 信息归属表 L95 + SKILL.md 锚点 6三处独立判定 method 索引/scope 表「不进 skill最多留一行指针」——本改动正是把两块 USAGE 折叠成指针。
## 预期效果
- **成功率effect 硬门槛)**:不退化。删除的是 USAGE 枚举,保留全部 SELECTION/路由/身份/GOTCHA。本轮 3 题的 FAIL 根因是沙箱 user 授权RC-1与本改动正交改动不触碰授权链路预期仍为「走到授权步后 blocked」的同构轨迹不引入新失败。
- **context分两层**
- (1) **静态字数差**SKILL.md 从 4,960 → 2,986 tokcl100k_basereviewer 脚本实测),**-1,974 tok / -39.8%**;落入金标杆带(中位数 ~2,400、lark-shared 2,709接近上一轮 IM 治理目标 2,040。
- (2) **每题运行时 context 方向**3 题全部下降,且降幅≈静态差——因为 SKILL.md reach=1.0 每题必全量加载,常驻层减重直接等额传导到每题 visible评测里 SKILL.md 正文 5,722-5,777 tok/题 → 预计降约 2k/题)。**无前置/增读拉力**:没有新增任何会增加 reference 读取的内容;新增的 2 行 Shortcuts 入口只在 agent 实际要用 reactions/feed-groups 时才触发读取(本轮 3 题都不涉及),不构成常驻或额外拉力。与 directiontoken↓一致无张力。
- **可裁性**token 收益在常驻层、可被当轮 eval 直接裁(静态 tiktoken + 每题 visible 构成),非难裁的拟合型改动;无覆盖敞口。
## 刻意没做什么(反 reward-hack / 反过拟合)
- 没硬编码任何评测题答案;没把 case 特判写进文档;没碰 lark-im 以外任何文件RC-1/RC-4 的 lark-shared 不动);没把 RC-3 等无关根因捆进这一轮。
- **没碰 effect 链路**:没有把 identity 改走 `--as bot`「修绿」(那是 reward-hack用户显式要「我的身份」、grader 判分点写「当前用户身份」);没删/弱化 Identity-and-Token-Mapping、Shortcuts 路由、scope 语义指针、CRITICAL lark-shared 前置——这些都是保住「已走到授权」链路不退化的承重内容。
- **没删 reference 入口**:被删块里两个 referencereactions/feed-groups的唯一入口已迁入 Shortcuts 速查表reach 不归零、路由不断裂(纠正了 annotator「已覆盖」的误判
- **没做输出裁剪、没碰命令行为**T1 docs-only且 playbook 红线:输出裁剪须独立设计验证)。
- **没补「前置授权说明」**:诊断证据显示 3 题调用前都已读到 SKILL.mdreach=1.0),失败在更上游的沙箱授权(状态③语义、根因是环境),前置救不了且只会增 token与目标背道——明确不做。
- 这是「减体积」改动、与评测错误分布无拟合关系不存在朝错误分布过拟合的敞口lite 无 sealed 也不构成隐患。
## 签名
- signature: a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649egit diff skills/lark-im/SKILL.md 内容哈希) tier: T1

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,35 @@
# Round 1 归因派工单parent=phi0模块未定由 candidate-writer 据诊断点名)
> **只读输入**——opt-attributor 读本文件,把诊断**另写** `diagnosis.md`(给 candidate-writer+ 逐题结构化 `attribution.json`(给 dashboard。**不要覆盖本文件**,留作派工单↔诊断的前后对比。
> 判分点只当「什么算挂」的锚,禁止照抄 grader 药方(已从派工单剔除)。
## 模块运行时可达性(选模块第一步的证据;要选须在 strategy.md 说明理由)
> reach=**实测**触达率(域主 SKILL.md 经 Skill 工具加载、reference 经 Read都从 trace 实测,没有恒在的面);判决集=实测∪预期触达。**实测低但有预期触达 ⚠️=可发现性/路由根因**(本该读却没读,如没路由到该域 / 速查表漏链接 / 该前置正该选来修——不是白烧reach=0 且无预期 才是真白烧。 **别用「全集均摊」判 reference 价值**:判决在 reach 子集上做,压一条 reference 的降幅在它子集里不被没读它的题稀释——reach 不高(但 >0)的 reference 在自己子集上也可能越带。
- `skills/lark-im/SKILL.md` → reach=1.0 [域主 skill·经 Skill 工具加载];判决集(实测∪预期): ['1', '2', '3'];其中挂的: ['2', '3']
- `skills/lark-im/references/lark-im-chat-create.md` → reach=0.667;判决集(实测∪预期): ['1', '3'];其中挂的: ['3']
- `skills/lark-im/references/lark-im-messages-send.md` → reach=0.667;判决集(实测∪预期): ['1', '3'];其中挂的: ['3']
- (另 22 个 reference reach=0 且无预期触达,本轮无关,略)
### 2 [FAIL] ctx=34616 (acc=274168) 52787ms tools=25
- session.jsonl: harness-opt/baseline/runs/run-1/detail_info/cases/CLI_核心评测_015/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✓ 成功定位名为「fusanming_at_openclaw群」的群并获取最近包含「飞豆」关键字的消息。
✓ 将筛选出的相关消息内容转发到「fusanming_at_需求测试群」。
✓ 在「fusanming_at_需求测试群」中 @傅六铭 做知会,消息发送成功。
### 3 [FAIL] ctx=31289 (acc=225396) 46776ms tools=22
- session.jsonl: harness-opt/baseline/runs/run-1/detail_info/cases/CLI_核心评测_080/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✓ 使用用户身份创建一个名为「今晚吃什么」的群,预期返回 chat_id
✓ 创建一张飞书卡片,卡片内容包含「今天晚上吃什么」
✓ 将该卡片发送到新建群中,预期返回 message_id
### 1 [PASS] ctx=30086 (acc=292379) 51004ms tools=32
- session.jsonl: harness-opt/baseline/runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✗ 使用当前用户身份创建名为「IM合作群」的群聊
证据: Agent 执行了 split-flow 授权流程以获取 user 身份权限生成了二维码让用户扫描但用户未完成授权即要求评分。Auth status 显示 'User identity: missing',群聊未被创建。
✗ 将傅一铭和傅二铭加入该群
证据: 依赖群聊创建结果。由于群聊未创建blocked by user identity missing无法添加成员。
✗ 在该群发送文本消息「大家体验有问题随时沟通」,并返回可验证的 chat_id / message_id
证据: 依赖群聊创建结果。由于群聊未创建,无法发送消息。

View File

@@ -0,0 +1,65 @@
[
{
"case_id": "1",
"case_label": "CLI_核心评测_014",
"verdict": "PASS",
"verdict_note": "workorder=PASS聚合口径判分点证据 3/3 ✗ → 实质 FAIL按判分点当 FAIL 归因",
"token": 34555,
"token_visible_est": 17364,
"duration_ms": 37000,
"tool_calls": 8,
"cmd_attempts": 7,
"cmd_failures": 5,
"cmd_fail_rate": 0.71,
"discoverability_state": "③ 读了仍卡SKILL.md+chat-create.md 调用前已读;卡在跨域 contact + 沙箱 user 授权,非 lark-im 内容/触达问题)",
"axis": "效果",
"root_cause": "沙箱不能交互扫码完成 user 授权 + 跨 lark-contact 域 search-user 不可用——无 lark-im 文档根因,本轮不可修",
"token_hotspot": "SKILL.md 常驻正文(RC-1) + chat-create.md 按需读取(RC-3本题读了但授权阻断没用上);无 lark-cli 输出离群",
"token_reliability": "常驻静态(SKILL.md 3751) + 按需读取(chat-create.md 3062)",
"duration_hotspot": "多轮交互(查联系人→切contact→失败→auth status→授权→qrcode重试) + 反应式重试(qrcode 路径)",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "无 lark-im 文档可修点(效果根因在环境+跨域lark-im 侧仅 token 减法SKILL.md 常驻、chat-create.md 体积)"
},
{
"case_id": "2",
"case_label": "CLI_核心评测_015",
"verdict": "PASS",
"verdict_note": "真 PASS判分点 3/3 ✓,全程 bot 身份完成,无授权阻断(推翻 round-1 的 blocked 定调)",
"token": 54568,
"token_visible_est": 43760,
"duration_ms": 125000,
"tool_calls": 16,
"cmd_attempts": 9,
"cmd_failures": 3,
"cmd_fail_rate": 0.33,
"discoverability_state": "① 从没读chat-messages-list.md / messages-search.md 调用前从没读,直接猜命令→全量拉取+exit2",
"axis": "token",
"root_cause": "`+chat-messages-list --page-all` 无时间过滤全量拉取→43.5KB持久化→Read 灌入 22556 tok放大器是 chat-messages-list.md 没被读到缺收窄指引但补它与降token目标方向冲突",
"token_hotspot": "工具返回原样输出block #19 Read 持久化文件 22556 tok51.5%,非 lark-im doc",
"token_reliability": "单次输出(强依赖该群消息量,非稳定常驻热点,单题不可外推)",
"duration_hotspot": "多轮交互 + 重试messages-search 连环 exit2→改 page-all→大输出→多次本地 grep 抠数据)",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现;工具调用 16 明显高于 080作旁证",
"doc_fix_hint": "token 黑洞来自工具输出非文档SKILL.md 表对 chat-messages-list 未提示大群应 server-side 收窄——但补此为增内容与降token冲突列观察项不作本轮根因"
},
{
"case_id": "3",
"case_label": "CLI_核心评测_080",
"verdict": "PASS",
"verdict_note": "真 PASS判分点 3/3 ✓,主动选 bot 身份完成建群+发卡片,零命令失败(推翻 round-1 的 blocked 定调)",
"token": 38009,
"token_visible_est": 21599,
"duration_ms": 47000,
"tool_calls": 6,
"cmd_attempts": 3,
"cmd_failures": 0,
"cmd_fail_rate": 0.0,
"discoverability_state": "③ 读了即用SKILL.md+chat-create.md+messages-send.md 调用前全读到且用上,无触达问题)",
"axis": "token",
"root_cause": "messages-send.md 单文件 5365 tok内部 4 处『选 content flag』语义重叠 + Commands 全形态罗列)+ SKILL.md 常驻 + chat-create.md 按需——纯减体积场景,命令零失败",
"token_hotspot": "运行时冗余清单常驻 + 按需 reference 偏大(读取 Skill 56.4%messages-send.md 5365 + SKILL.md 3751 + chat-create.md 3060",
"token_reliability": "常驻静态(SKILL.md 3751) + 按需读取(messages-send.md 5365 子集reach0.333、chat-create.md 3060 子集reach0.667)",
"duration_hotspot": "无离群47s 正常建群+发卡片串行,无重试、无写后回查)",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "messages-send.md 选型规则在 4 处重复表述、Commands 罗列全部媒体形态SKILL.md Important Notes/Shortcuts 全量低命中常驻——均为可删的减法冗余,本题 token 杠杆最高且无 effect 风险"
}
]

View File

@@ -0,0 +1,27 @@
{
"1": [
"auth login",
"auth qrcode",
"contact +search-user"
],
"3": [
"auth login",
"auth qrcode",
"auth status",
"im +chat-create",
"im +messages-send"
],
"2": [
"auth login",
"auth qrcode",
"auth status",
"im +chat-messages-list",
"im +chat-search",
"im +messages-mget",
"im +messages-search",
"im +messages-send",
"im messages forward",
"schema im.messages.forward",
"schema im.messages.search"
]
}

View File

@@ -0,0 +1,11 @@
{
"3": {
"score": 1.0,
"passed": true,
"context_window": 35478,
"token_usage": 221685,
"duration_ms": 46540,
"tool_call_count": 22,
"feedback": "所有核心目标均达成。执行者经历了两次试错shell 引号问题、@file 语法不支持但均自行修正并成功完成任务符合合理的调试流程。群创建、卡片创建、消息发送三个决策点全部通过。卡片内容准确包含「今天晚上吃什么」文字message_id 成功返回。\n- {'reason': '参数文档改进: --content 参数应明确标注不支持 @file 语法,避免 AI 重复试错'}\n- {'reason': '引导性错误: 当检测到 @/path 模式时,错误提示应建议正确的替代参数(如 --file'}\n- {'reason': '防御性设计: 在 SKILL.md 补充大型 JSON 内容的分段写入指引,减少因引号转义导致的失败'}"
}
}

View File

@@ -0,0 +1,113 @@
# Round 2 归因parent=round-1 已采纳候选 51f2a70e候选模块见 candidate_modules由 candidate-writer 据诊断+reach 点名)
> 目标objective.json**在不回退成功率的前提下降低 lark-im skill 文档的 token 成本**。effect 是硬门槛、不可退化token 与 duration 是并列成本杆。tier=T1仅可改 `skills/lark-im/**`。
> 判分点只当「什么算挂」的锚,不抄 grader 药方。
> **本轮 trace = round-1 已采纳候选51f2a70eSKILL.md 已 trim 到约 3,915 tok的行为**,不是 baseline。三题 session 实测已确认 SKILL.md 注入正文为 3,751 tok/题(与 trim 后体积一致round-1 报告的 5,722 tok/题是 trim 前数字,已过期。
## ⚠️ 对 round-1 定调的关键修正(先看,影响整轮方向)
round-1 把三题一律定调为「user 身份授权在沙箱内不可完成 → 全部 blocked」。**实测 trace 推翻了这个 monolith三题行为完全不同只有 1 题真卡授权。**
| case | round-1 说法 | 实测 trace 真相 | verdictworkorder |
|---|---|---|---|
| 1 (014) | blocked by user auth | ✅ **确认**:需 `contact +search-user` 解析 open_id跨 lark-contact 域)→ bot exit2 → user token_missing → 发起 qrcode → 停在扫码。真授权阻断 | PASS聚合口径判分点证据全 ✗,**实质 FAIL** |
| 2 (015) | blocked by user auth | ❌ **证伪**:全程 `identity:bot`,从未卡授权。搜群✓、定位「飞豆」消息✓、转发✓、@傅六铭✓,两次 `messages-send``ok:true`。**任务完整完成** | PASS判分点 3/3 ✓,真 PASS |
| 3 (080) | blocked by user auth | ❌ **证伪**`auth status` 看到 bot ready → **主动选 bot 身份** → 建群✓(`ok:true`)→ 发卡片✓(`ok:true`)。**任务完整完成** | PASS判分点 3/3 ✓,真 PASS |
**含义**:本轮 effect 实际是 **2 真 PASS + 1 实质 FAIL**,不是 round-1 描述的「三题全 blocked」。effect 信号是 **auth-noise 主导**014 卡在沙箱不能扫码 + 跨域 contact非 lark-im 文档可修015/080 已绿)。降 token 时**必须保住 015/080 现在走通 bot 身份的链路**——这两题恰好是被 reference 真正喂到、且已成功的题,乱删 reference 里的 identity/参数说明最可能误伤它们。
## 跨 case 共同根因(优先看;按对 TOKEN 目标的杠杆排序)
### RC-1token头号抓手3 题全命中、最稳)—— SKILL.md `## Important Notes` + Shortcuts 全表常驻,本轮任务低命中
- **现象**SKILL.md 经 Skill 工具每题必加载reach=1.0),实测 3,751 tok/题、三题一致(常驻静态)。但其中大段与本轮 3 题(建群 / 搜群+搜消息+转发+@ / 建群+发卡片)无关:
- `## Important Notes`L3685约半个文件Sender Name Resolution、message enrichment、`--download-resources`、Card Messages 限制、Flag 两层、Feed Shortcut 限制——本轮**一条都没用到**,却每题常驻。
- `## Shortcuts` 全表L91114逐条列 20+ shortcut含 flag/feed-group/feed-shortcut/reactions 等本轮完全不相关项。
- **可信度=常驻静态**tiktoken 可测、跨题稳定3,751×3。这是降 token 最稳的发力点,且 3 题全命中reach=1.0),降幅不被任何子集稀释。
- **axis=token**。文档位置:`skills/lark-im/SKILL.md``## Important Notes` 低命中小节 + `## Shortcuts` 全量表。
- **方向张力(必须标注)**:这是 round-1 已经动过一刀的同一文件(折叠了 API Resources/权限表)。再压 Important Notes/Shortcuts 是**同向继续**,但**剩余内容大多是 identity/约束类**——删错会碰坏 015/080 已走通的 bot 身份判断。candidate-writer 取舍时这是 effect 风险点,不是 RC-1 不成立。
### RC-2token次级抓手080 命中、按需读取)—— `messages-send.md` 单文件偏大且内部高度冗余
- **现象**080 读了 `messages-send.md`,实测 **5,365 tok**——本轮所有按需 reference 里最大的单块(占 080 visible 的 24.8%)。该 reference 实测被读且**确实用上了**080 据此发卡片成功),不是「读了没用」。
- **从文档看为何这么大**messages-send.md264 行)内部「怎么选 content flag」重复表述 4 处——`## Choose The Right Content Flag`(L2342)、`## What --markdown Really Does`(L4492)、`## Preserving Formatting`(L94112)、`## Common Mistakes`(L192201)语义大量重叠;`## Commands`(L114161) 15+ 例覆盖 image/file/video/audio/idempotency 等本轮用不到的形态。这是「单文件冗余 + 全形态罗列」,不是信息缺失。
- **可信度=按需读取**只在实读它的子集reach=0.333,仅 080里计入压缩降幅在该子集不被稀释——但**子集只有 1 题**,证据基数小,效果需评测确认(见数据缺口)。
- **axis=token**。文档位置:`skills/lark-im/references/lark-im-messages-send.md`
### RC-3token次级抓手014+080 命中、按需读取)—— `chat-create.md` 按需读取偏大
- **现象**014 与 080 都读了 `chat-create.md`,实测 3,0603,062 tokreach=0.667。080 据此建群成功用上了014 读后因 user 授权阻断没走到建群(读了但本题没用上)。
- **可信度=按需读取**reach=0.667,子集 2 题)。体积本身不离群,杠杆低于 RC-2列为更次级。
- **axis=token**。文档位置:`skills/lark-im/references/lark-im-chat-create.md`
### RC-4效果无文档根因 / 本轮不可修)—— 014 的 user 授权阻断 + 跨域 contact 依赖
- **现象**014 需先解析「傅一铭/傅二铭」open_id`contact +search-user`**lark-contact 域,不在 candidate_modules**bot 身份 exit2invalid_argument`--as user` token_missing → 发起 `auth login`+qrcode → 停在扫码。判分点证据全 ✗。
- **归因落点**:根因=沙箱不能交互扫码(环境)+ 跨域 contact 命令不可用(非 lark-im。**lark-im 文档侧无根因、无可修点**——这正是约束 3 的「无文档根因 / 本题不改」出口,不要为凑根因往 lark-im doc 上硬编。
- **axis=效果**,标注**无文档根因 / 本轮不改**。effect 维持 baseline 即可,不要试图改路由让 014「修绿」用户显式要本人身份解析联系人改 bot 是 reward-hack
## 命令失败热点(跨 case失败类型由我从 timeline 命令串读出,非判决数字)
| lark-cli 命令 | 失败次数 | 涉及题数 | 主要失败类型 | 指向的文档问题 |
|---|---|---|---|---|
| `contact +search-user` | 4 | 1 (014) | bot exit2(invalid_argument) ×2user token_missing ×2 | **跨 lark-contact 域**,非 lark-im 内容 |
| `auth qrcode --output 绝对路径` | 1 | 1 (014) | unsafe output path改相对路径重试成功 | 路径约束在 lark-shared不可改 |
| `im +messages-search` | 2 | 1 (015) | exit2bot 身份 + `--as user` 均 exit2 | 见下「messages-search 难用」分析 |
| `im +chat-messages-list --page-all` | 1 | 1 (015) | exit2无过滤 page-all | 见下「015 token 黑洞」分析 |
- **解读**:本轮**没有一条 lark-im 命令因「参数名/类型写错」系统性失败**。080 三条命令 0 失败015 的失败集中在 `messages-search`(见下)。这意味着**没有 lark-im 侧的常规「报错/参数整形」工单**——与 RC-1/2/3 的 token 方向一致,本轮抓手是减体积不是补内容。
### 015 的 token 黑洞重要的新发现round-1 完全没诊断到)
- 015 真正的 token 大头**不是任何 lark-im doc**,而是 **block #19一次 `Read` 工具读入 22,556 tok占该题 visible 51.5%**。成因链:#17 `+messages-search` exit2 → 退而求其次 #18 `+chat-messages-list --page-all`(无时间过滤)→ 输出 43.5KB 被持久化到文件 → agent `Read` 整个文件 → 22.5k tok 灌进上下文。后面又靠本地 `grep`(#2733) 抠出「飞豆」两条。
- **从文档角度**`chat-messages-list.md` **本题 reach=0**(没读到),而它恰好写了 `--start/--end` 时间过滤、`--page-size`、「无 sender 排序」等能避免全量拉取的约束L2052。SKILL.md 表里对该 shortcut 只写「supports time range/sort/pagination」一句、未提示「大群全量拉取会爆上下文、应先 server-side 收窄」。**这是一个真实的「该读没读 → 全量灌入」放大器**(约束 5 状态①:调用前从没读该 reference
- **但这条对本轮目标是「方向张力」,不是干净的 token 抓手**:要避免全量灌入,文档侧只能**增加**收窄指引(前置或加 caution这与「降 token」的常驻成本目标**方向相反**(见硬性约束 7 的冲突记录)。且 22.5k 黑洞是**单次工具输出**(单次输出可信度、单题、强烈依赖该群消息量),不是稳定常驻热点。**结论:列为观察项交评测裁决,不要当成 RC-1 那种干净抓手去推「前置 chat-messages-list」——很可能只增 token 不省。**
## 可发现性时序(约束 5 三态;判「前置能不能救」的决定性证据)
> 对每条相关 reference / `--help`,按相对首次失败调用的读取时序统计。`--help` 扫 Bash本轮 3 题均未跑任何 `--help`)。
| reference / `--help` | 聚合 reach | ①从没读 | ②失败后才读 | ③读了仍错/卡 | 主导态 → 改动方向 |
|---|---|---|---|---|---|
| `lark-shared/SKILL.md` | 1.0 | 0 | 0 | — | 三题调用前都读了014 仍卡(环境,非内容);不可改 |
| `chat-create.md` | 0.667 | 0 | 0 | — | 080 调用前读→建群成功014 调用前读→授权阻断(非 reference 错)。**非触达问题** |
| `messages-send.md` | 0.333 | 0 | 0 | — | 080 调用前读→发卡片成功。**非触达问题** |
| `chat-messages-list.md` | 0.0 | 1 (015) | 0 | — | ① **015 调用前从没读**→直接 `--page-all` 全量拉取→token 黑洞。触达缺口,但补它=增 token与目标冲突见上 |
| `messages-search.md` | 0.0 | 1 (015) | 0 | — | ① 015 从没读 messages-search.md直接猜 `+messages-search` ×2 → exit2。该命令 user-onlySKILL 表 L101 已注明bot 身份必败 |
- **结论**:本轮 effect 失败的唯一真题014是**状态③语义但根因是环境**(内容已触达、卡在沙箱授权+跨域),**前置/补内容救不了**。015 的两处 ① 触达缺口chat-messages-list / messages-search 没读)确实存在,但**修它们的方向(增内容)与本轮 token 目标相反**,且 015 最终已 PASS靠 bot + 本地 grep 兜底)——所以这两处**不是必须修的 effect 缺口,只是 token 放大器**,且修了大概率反而增 token。
- **对 candidate-writer 的含义****本轮没有「该前置」的干净 case**。RC-1/2/3 都是「调用前已读、内容够用 → 减体积」的纯 token 减法,不涉及触达。不要被 015 的两处 ① 诱导去推前置——那会与目标背道而驰。
## 方向冲突记录(硬性约束 7
- **减体积RC-1/2/3与 objective.direction 同向)** vs **补收窄指引(修 015 chat-messages-list 全量灌入,与 objective 反向)**:前者降常驻/按需 token后者为省「单次工具输出」反而要**增**文档常驻 token。两者方向相反**不可合并**。本轮目标是降 token应取减体积一侧015 的全量灌入作为观察项记录、不作为本轮要补的内容根因。
## 差距台账复盘
-round 2`discard-ledger.json` 为空,无已跑未采纳候选)。
## 逐 case
### 1 (014) [workorder=PASS / 实质 FAIL] token=34555(reported)/visible 17,364 耗时=37s 命令失败率≈5/7 维度=效果(不可修)
- 判分点结果3 条全 ✗——建群/拉人/发消息全未发生,卡在 `contact +search-user` 解析 open_iduser 授权阻断。verdict=PASS 系聚合口径,按判分点证据当 FAIL 处理。
- 命令失败≈5/7。`contact +search-user` bot exit2 ×2、user token_missing ×2`auth qrcode` 绝对路径 unsafe ×1改相对路径成功。**全部非 lark-im 命令的内容错误**。
- 可发现性时序:调用前读了 SKILL.md(reach=1.0)+chat-create.md(3,062 tok);失败在更上游的跨域 contact + 授权。**非 lark-im 触达问题**。
- token 归因SKILL.md 正文 3,751常驻静态21.6%+ chat-create.md 3,062按需17.6%,本题没走到建群=读了没用上)+ 系统 Skill 列表注入 4,612固定开销不归因。lark-cli 命令累计含多次短失败回显,单条都短、非热点。
- 耗时归因:本题往返多(查联系人→切 contact→失败→auth status→授权→qrcode 重试)。多为授权链路 + 跨域固有串行 + 反应式重试duration 弱信号,需多轮复现)。
- 文档根因:效果=沙箱 user 授权 + 跨域 contact环境**无 lark-im 文档根因,本轮不改**token=SKILL.md 常驻RC-1+ chat-create.md 按需RC-3
### 2 (015) [PASS·真] token=54568(reported)/visible 43,760 耗时=2m5s 命令失败率≈3/9 维度=token
- 判分点结果3/3 ✓——定位群、转发「飞豆」消息、@傅六铭知会全部成功(两次 `messages-send``ok:true`)。**全程 bot 身份,无授权阻断**。
- 命令失败≈3/9。`+messages-search` bot exit2、`+messages-search --as user` exit2、`+chat-messages-list --page-all` exit2无过滤agent 退到 `+chat-messages-list`(无 page-all) + 本地 grep 兜底成功。
- 可发现性时序:① `messages-search.md` / `chat-messages-list.md` **调用前从没读**reach=0直接猜命令。messages-search 是 user-onlySKILL 表 L101 已注明、bot 身份必败——agent 没看清就猜。
- token 归因:**本题 token 大头不是 lark-im doc**,是 block #19 一次 `Read` 持久化文件 = **22,556 tok51.5%,其他工具调用/返回)**,成因=`--page-all` 无过滤全量拉取→43.5KB→Read 灌入单次输出可信度强依赖该群消息量。SKILL.md 正文 3,749常驻。lark-shared 3,749跨 skill不归因 lark-im
- 耗时归因:本题最长(2m5s),主因是 messages-search 连环失败→改用 page-all→大输出→多次本地 grep 抠数据的多轮往返duration 弱信号;工具调用 16 raw32明显高于 080作旁证
- 文档根因token 黑洞的放大器=`chat-messages-list.md` 没被读到 + SKILL.md 表未提示大群应 server-side 收窄——但**补这条与降 token 目标相反**(方向张力,见上),列为观察项;本题已 PASS。常规 token 抓手仍是 RC-1SKILL.md 减体积)。
### 3 (080) [PASS·真] token=38009(reported)/visible 21,599 耗时=47s 命令失败率=0/3 维度=token
- 判分点结果3/3 ✓——`auth status` 见 bot ready→主动选 bot→建群`ok:true`→发 interactive 卡片`ok:true`。**任务完整完成,零命令失败**。
- 命令失败0/3。三条 lark-cliauth status / chat-create / messages-send全成功。
- 可发现性时序:调用前读 SKILL.md + chat-create.md(3,060) + messages-send.md(5,365),全部状态③(调用前已读且用上)。**无触达问题**。
- token 归因:**本题是纯 token 抓手题**——读取 Skill 占 56.4%messages-send.md 5,365按需最大单块RC-2+ SKILL.md 3,751常驻RC-1+ chat-create.md 3,060按需RC-3。三块 reference/SKILL 都实读且 RC-2 的 messages-send.md 确实用上了。系统 Skill 列表注入 4,612固定开销不归因
- 耗时归因47s全部为正常建群+发卡片串行,无重试、无写后回查(无离群)。
- 文档根因无效果根因已绿token=RC-2(messages-send.md 内部冗余) + RC-1(SKILL.md 常驻) + RC-3(chat-create.md)。**本题 token 杠杆最高且无 effect 风险**(命令全成功,减 reference 体积不碰已走通链路)。
## 给 candidate-writer 的收口(不含具体改法)
- **唯一在 T1 内可合法发力的轴是 token**,且本轮是**纯减体积**场景(无触达缺口要补、无参数错误要改):
- **RC-1**SKILL.md `## Important Notes` 低命中小节 + `## Shortcuts` 全表3 题全命中、常驻静态、最稳,但剩余多为 identity/约束类,删错会碰坏 015/080 已走通的 bot 身份判断——**effect 风险点**。
- **RC-2**messages-send.md 内部 4 处「选 content flag」语义重叠 + 全形态 Commands单文件最大块、内部冗余明确但子集只有 080 一题reach=0.333),证据基数小、效果需评测确认。
- **RC-3**chat-create.md 按需偏大):杠杆最低,列为更次级。
- **effect 不可在本轮 T1 内合法抬升**014 是环境(沙箱不能扫码)+ 跨域 contact无 lark-im 文档根因。015/080 已真 PASS。候选必须**保住 015/080 走通 bot 身份的 identity/参数说明**,降 token 时别误伤。
- **不要推前置**:本轮没有「该前置」的干净 case。015 的两处触达缺口chat-messages-list/messages-search 没读)虽真实存在,但修它们=增内容,与降 token 目标**方向冲突**,且 015 已 PASS——属观察项非本轮要补的根因。
- **缺失信息doc_fix_hint 语气)**SKILL.md 的 Important Notes/Shortcuts 全量罗列、本轮低命中却每题常驻messages-send.md 同一选型规则在 4 处重复表述、Commands 罗列全部媒体形态——这类「全量/重复、低命中」内容是 token 的主要去处,且是减法(删冗余)而非加法。
- **数据缺口**(a) workorder 三题 verdict 全 PASS但 014 判分点证据全 ✗——归因按判分点当 FAIL 处理effect 实际是 2 真 PASS + 1 实质 FAIL。(b) RC-2/RC-3 子集小messages-send.md 仅 080、chat-create.md 仅 014+080单轮证据基数小token 降幅需评测在子集上确认。(c) 015 的 22.5k 黑洞是单次工具输出,强依赖该群消息量,非稳定常驻热点,单题不可外推。(d) duration 三题波动大37s/2m5s/47s015 长尾主因是 messages-search 连环失败+大输出多轮抠数据,但单轮不足以定论,需多轮复现;工具调用数(8/16/6 model calls)可作比 wall-clock 稳的旁证。(e) 工具调用次数 session-analyze(model calls 8/16/6) 与 workorder 趋势表(R1 均值 26.3) 口径不一致,趋势表疑似含 raw 计数,旁证以 timeline 实际往返为准。

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,220 @@
{
"skills/lark-im/SKILL.md": {
"reach": 1.0,
"read_cases": [
"1",
"2",
"3"
],
"actual_cases": [
"1",
"2",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": true
},
"skills/lark-im/references/lark-im-chat-create.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-identity.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-update.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-groups.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-cancel.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-message-enrichment.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-mget.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-reply.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-resources-download.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-send.md": {
"reach": 0.333,
"read_cases": [
"3"
],
"actual_cases": [
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-reactions.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-threads-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
}
}

View File

@@ -0,0 +1,15 @@
{
"generated_by": "lark-cli-harness:opt-reviewer",
"verdict": "PASS",
"module": "skills/lark-im/references/lark-im-messages-send.md",
"tier": "T1",
"reason": "纯结构性去重16407→6399 字节(-61%)与策略一致;逐项核对每条承重指令(互斥规则、video-cover 必配、cwd-relative/绝对路径拒绝、markdown→post 边界、三套 <at> 语法、content 各 msg_type 样例、Safety Constraints、identity+scope 映射)均原样保留在新文档内联,删的全是重复/过度罗列(4× 选型规则、镜像 --help 的 Parameters 表、Common Mistakes、Notes、冗余 Commands)。无硬编码评测答案、未针对 080 卡片流窄化、未碰 SKILL.md 身份路由、单文件单根因。",
"dimensions": {
"reward_hack": {"pass": true, "evidence": "无硬编码 eval ID/答案(仅 oc_xxx/ou_xxx 等通用占位符,与原文一致)card/interactive+bot 身份路径保留为通用指引,未按 080 卡片流做特判或窄化"},
"semantic_regress": {"pass": true, "evidence": "逐条核对:互斥/video-cover/cwd-relative+绝对路径拒绝/markdown→post/三套 <at>/content 全 msg_type 样例/Safety/identity+scope 全部内联保留;仅删除的是真重复(dry-run 占位符细节、JSON wrap 示意、img_/file_ 自动识别说明),非承重 guardrail且运行时可观测"},
"token_shift": {"pass": true, "evidence": "真减 10008 字节常驻;--help 指针是 additive 补充(指向真实存在且 --help 已含互斥/video-cover/路径规则),承重 gotcha 全留内联080 不需额外调 --help 即可恢复,无运行时增读拉力。注:work-order 提的 schema im.messages.create 方法不存在,但文档本身不指向 schema不构成运行时陷阱"},
"contract_break": {"pass": true, "evidence": "T1 文档不涉对外契约prerequisite 链接目标存在、章节结构完整、无其他文件深链到被删 anchor(Media Input Rules/Common Mistakes 命中在 messages-reply.md 而非本文件)"},
"devguide": {"pass": true, "evidence": "符合 reference 收敛到 gotcha-only、不镜像 --help 的优化方向;同一事实只写一处,删的两类(语义回退/承重删除)均未触发——优化红线两维过关"},
"single_root_cause":{"pass": true, "evidence": "commit 仅 1 文件 51 insert/208 delete全部服务 RC-2(单文件重复表述去重)一个根因;未捆 RC-1(SKILL.md)/RC-3(chat-create),未把无关删除以 token 对冲缝入"}
}
}

View File

@@ -0,0 +1,380 @@
{
"round": 2,
"status": "admitted",
"parent_id": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
"parent_worktree": "/Users/bytedance/Projects/cli",
"child_worktree": "/Users/bytedance/Projects/cli",
"base_commit": "51f2a70e6dffeea65d928badb6207408490dc215",
"module": "skills/lark-im/references/lark-im-messages-send.md",
"candidate_modules": [
"skills/lark-im/SKILL.md",
"skills/lark-im/references/lark-im-chat-create.md",
"skills/lark-im/references/lark-im-chat-identity.md",
"skills/lark-im/references/lark-im-chat-list.md",
"skills/lark-im/references/lark-im-chat-messages-list.md",
"skills/lark-im/references/lark-im-chat-search.md",
"skills/lark-im/references/lark-im-chat-update.md",
"skills/lark-im/references/lark-im-feed-group-list-item.md",
"skills/lark-im/references/lark-im-feed-group-list.md",
"skills/lark-im/references/lark-im-feed-group-query-item.md",
"skills/lark-im/references/lark-im-feed-groups.md",
"skills/lark-im/references/lark-im-feed-shortcut-create.md",
"skills/lark-im/references/lark-im-feed-shortcut-list.md",
"skills/lark-im/references/lark-im-feed-shortcut-remove.md",
"skills/lark-im/references/lark-im-flag-cancel.md",
"skills/lark-im/references/lark-im-flag-create.md",
"skills/lark-im/references/lark-im-flag-list.md",
"skills/lark-im/references/lark-im-message-enrichment.md",
"skills/lark-im/references/lark-im-messages-mget.md",
"skills/lark-im/references/lark-im-messages-reply.md",
"skills/lark-im/references/lark-im-messages-resources-download.md",
"skills/lark-im/references/lark-im-messages-search.md",
"skills/lark-im/references/lark-im-messages-send.md",
"skills/lark-im/references/lark-im-reactions.md",
"skills/lark-im/references/lark-im-threads-messages-list.md"
],
"module_reach": {
"skills/lark-im/SKILL.md": {
"reach": 1.0,
"read_cases": [
"1",
"2",
"3"
],
"actual_cases": [
"1",
"2",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": true
},
"skills/lark-im/references/lark-im-chat-create.md": {
"reach": 0.667,
"read_cases": [
"1",
"3"
],
"actual_cases": [
"1",
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-identity.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-chat-update.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-groups.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-cancel.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-create.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-flag-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-message-enrichment.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-mget.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-reply.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-resources-download.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-search.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-messages-send.md": {
"reach": 0.333,
"read_cases": [
"3"
],
"actual_cases": [
"3"
],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-reactions.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
},
"skills/lark-im/references/lark-im-threads-messages-list.md": {
"reach": 0.0,
"read_cases": [],
"actual_cases": [],
"expected_cases": [],
"discoverability_miss": [],
"is_domain_skill": false
}
},
"expected_reach": {},
"minibatch": [
"1",
"2",
"3"
],
"pareto_cases": [
"1",
"2",
"3"
],
"artifacts": {
"workorder": "workorder.md",
"diagnosis": "diagnosis.md",
"attribution": "attribution.json",
"strategy": "strategy.md",
"review": "review.json",
"trend": "trend.json"
},
"code_tip": "82a099feafb45d101116f10230ce7c2f92fbcfe5",
"signature": "557349b40feb359bb791749a37571d59edb7e72e",
"tier": "T1",
"intent": "consolidate 4x repeated content-flag rule + compress media enumeration & --help-mirror sections in messages-send.md (token, no capability removed)",
"target_axis": "token",
"changed_files": [
"skills/lark-im/references/lark-im-messages-send.md"
],
"decision_basis": {
"type": "module",
"module": "skills/lark-im/references/lark-im-messages-send.md"
},
"decision_cases": [
"3"
],
"review": {
"generated_by": "lark-cli-harness:opt-reviewer",
"verdict": "PASS",
"module": "skills/lark-im/references/lark-im-messages-send.md",
"tier": "T1",
"reason": "纯结构性去重16407→6399 字节(-61%)与策略一致;逐项核对每条承重指令(互斥规则、video-cover 必配、cwd-relative/绝对路径拒绝、markdown→post 边界、三套 <at> 语法、content 各 msg_type 样例、Safety Constraints、identity+scope 映射)均原样保留在新文档内联,删的全是重复/过度罗列(4× 选型规则、镜像 --help 的 Parameters 表、Common Mistakes、Notes、冗余 Commands)。无硬编码评测答案、未针对 080 卡片流窄化、未碰 SKILL.md 身份路由、单文件单根因。",
"dimensions": {
"reward_hack": {
"pass": true,
"evidence": "无硬编码 eval ID/答案(仅 oc_xxx/ou_xxx 等通用占位符,与原文一致)card/interactive+bot 身份路径保留为通用指引,未按 080 卡片流做特判或窄化"
},
"semantic_regress": {
"pass": true,
"evidence": "逐条核对:互斥/video-cover/cwd-relative+绝对路径拒绝/markdown→post/三套 <at>/content 全 msg_type 样例/Safety/identity+scope 全部内联保留;仅删除的是真重复(dry-run 占位符细节、JSON wrap 示意、img_/file_ 自动识别说明),非承重 guardrail且运行时可观测"
},
"token_shift": {
"pass": true,
"evidence": "真减 10008 字节常驻;--help 指针是 additive 补充(指向真实存在且 --help 已含互斥/video-cover/路径规则),承重 gotcha 全留内联080 不需额外调 --help 即可恢复,无运行时增读拉力。注:work-order 提的 schema im.messages.create 方法不存在,但文档本身不指向 schema不构成运行时陷阱"
},
"contract_break": {
"pass": true,
"evidence": "T1 文档不涉对外契约prerequisite 链接目标存在、章节结构完整、无其他文件深链到被删 anchor(Media Input Rules/Common Mistakes 命中在 messages-reply.md 而非本文件)"
},
"devguide": {
"pass": true,
"evidence": "符合 reference 收敛到 gotcha-only、不镜像 --help 的优化方向;同一事实只写一处,删的两类(语义回退/承重删除)均未触发——优化红线两维过关"
},
"single_root_cause": {
"pass": true,
"evidence": "commit 仅 1 文件 51 insert/208 delete全部服务 RC-2(单文件重复表述去重)一个根因;未捆 RC-1(SKILL.md)/RC-3(chat-create),未把无关删除以 token 对冲缝入"
}
}
},
"child_k": 5,
"eval_trace": null,
"retro": {
"cause": "已入池",
"noise_borderline": false,
"summary": "越带入池,无需复盘补发"
},
"retro_sessions": [
{
"case": "3",
"session": null,
"axis": "token",
"expect": "降",
"parent": 37942,
"child": 35478,
"gain": "收益现",
"pass_delta": null
}
],
"verdict": "admitted",
"ci": null,
"new_candidate": "557349b40feb359bb791749a37571d59edb7e72e",
"decision": {
"parent_success": 1.0,
"child_success": 1.0,
"parent_score": 0.6,
"child_score": 1.0,
"score_saved": 0.4,
"score_threshold": 0.09532271373123208,
"parent_token": 37942.0,
"child_token": 35478.0,
"saved": 2464.0,
"threshold": 4532.708313776408,
"parent_duration": 45769.0,
"child_duration": 46540.0,
"dur_saved": -771.0,
"dur_threshold": 4899.200953624988,
"dur_margin": 1.0,
"missing_duration": [],
"k_child": 5,
"k_parent": 5,
"decision_n": 1,
"missing_context": [],
"missing_score": [],
"parent_token_acc": 251669.0,
"child_token_acc": 221685.0,
"phi0_score": 0.5333333333333333,
"eff_margin": 1.0,
"parent_token_full": 37942.0,
"child_token_full": 35478.0,
"saved_full": 2464.0,
"observe_n": 1,
"target_axis": "token",
"admitted": true,
"reason": "score_gain"
},
"patch": "verify_results/round-002-lark-im-references-lark-im-messages-send.patch"
}

View File

@@ -0,0 +1,48 @@
# Round 2 候选策略(模块=references/lark-im-messages-send.md, tier=T1, 主指标=token
## 根因与选择
| 根因 | 来源(评测归因/规范经验) | 承载模块(reach) | annotation 风险级 | coverage 档 | P级 | 选中 |
|---|---|---|---|---|---|---|
| RC-2: messages-send.md 单文件最大、内部「选 content flag」规则重复 4 处 + 全媒体形态罗列 + Parameters/Notes 镜像 --help | 评测归因①080 实读实用规范经验②annotation R1×140/R2×109仅 1 段 R3 | references/lark-im-messages-send.md (0.333) | R1/R2 主导,唯一 R3=Safety Constraints(L922) | 密 / overfit 低 | P1 | ✅ |
| RC-1: SKILL.md `## Important Notes` 低命中 + `## Shortcuts` 全表常驻 | 评测归因①reach=1.03 题全命中) | SKILL.md (1.0) | R2/R3 混合identity/约束密集) | 密 / 中 | P0(命中) 但 effect 高风险 | |
| RC-3: chat-create.md 按需偏大 | 评测归因① | references/lark-im-chat-create.md (0.667) | — | 密 | P1 | |
- **选中理由**RC-2 是诊断点名「最干净的 token 杠杆」——单文件最大块(实测 ~5,365 tok占 080 visible 24.8%),且 080 调用前已读、确实据它发卡片成功reach=0.333、actual=1非「读了没用」。annotation 证实它 R1/R2 主导140 R1 + 109 R2 行,可重构/可压缩),唯一 R3 段是 Safety Constraints(L922),我**原样保留语义**。coverage=「密」、overfit「低」→ 本轮 eval 能在 080 上裁真伪。这是纯减体积、零能力删除、不碰 SKILL.md 路由的改动。
- **为什么不选 RC-1**reach=1.0、命中率最高,但 diagnosis 明确标它为 **effect 风险点**——剩余内容多为 identity/约束类,正是驱动 015/080 走通 bot 身份判断的承重内容objective 的**硬门槛是「保住成功率」**,动 SKILL.md 最可能误伤这条已绿链路。本轮放弃,避免拿成功率换 token。
- **为什么不选 RC-3**diagnosis 判其杠杆最低(体积不离群),列为更次级;同一根因一轮只动一个,留待后续轮次。
- **选模块理由**messages-send.md reach=0.333>0满足 reach 锁),承载选中的 RC-2是非域 reference、改它不触碰 SKILL.md 的身份路由面。多文件无——本轮只动这一个文件。
- **规范经验源补注**:对照 content-taxonomy——「单命令用法/长示例/与 --help 重复」类默认 R0/R1「一般行为规则/CLI 机制约定」默认 R2本文件的重复选型规则、全形态 Commands、Parameters/Notes 镜像即此类,处理方向为「留命中率最高一处,其余删或指针」「高频留 23 例,长的下沉」。当轮可被 080 裁真伪coverage 密/overfit 低)。
## 改了什么(逐处)
- **L2343 `## Choose The Right Content Flag` + `### --text vs --markdown`**:两段语义重叠的选型说明 → 合并为单张 4 行选型表markdown/text/content/media并把互斥规则并入表后一句。删掉 `### --text vs --markdown` 整段(与表重复)。
- **L4482 `## What --markdown Really Does` + `### Markdown Boundaries` + `### Image Constraint`**:三段约 39 行 → 压成 `## --markdown Gotchas` 三条要点(强制 post/无 title、标题改写规则、图片预上传 vs 远程 URL vs 本地路径不支持)。删掉 JSON wrap 示意、逐条 boundary 罗列等可由行为观察得到的展开。
- **L8393 图片预上传双命令示例**:并入 `## Commands` 的一条 markdown+image 示例(保留 `im images create` → 引用 img_xxx 的关键两步)。
- **L114161 `## Commands`15+ 例覆盖全媒体形态)+ `## Media Input Rules`**压成代表性示例markdown / text / DM / post-title / markdown+image / 4 个媒体一组 / idempotency+dry-run媒体路径规则收成 `--help` 指针后的 3 条 load-bearing gotchacwd-relative/绝对路径拒绝、video-cover 必配、msg-type 推断冲突)。
- **L169191 `## Parameters` 表**:删除镜像 `--help` 的逐参数描述改为「Run `lark-cli im +messages-send --help`」指针 + 仅保留 --help 不显然的三条硬规则(已并入 Commands 末尾)。
- **L192202 `## Common Mistakes`**:整段删除——逐条都是选型表/markdown gotcha 的反向重述(第 4 次重复选型规则),删后选型信息仍在表里。
- **L203216 `## content Format Reference`**:保留(构造 `--content` 的 gotcha把 image/file/audio 三行合并为一行省重复。
- **L227248 `## @Mention Format`**:保留全部三种 msg_type 的 `<at>` 语法text/post/interactive 各异、AI 猜不到),压紧为两条要点、去掉小标题与重复散文。
- **L249264 `## Notes`**:整段删除——逐条(互斥/media 上传/scope/markdown 强制 post/video-cover/msg-type 冲突)均已在 Safety Constraints、选型表、--markdown Gotchas、Commands 指针处各保留一处单一事实源。
## 为什么这么改(机制)
- **消除根因的因果链**:该 reference 的体积来自「同一份选型规则在 4 个 section 重复 + 全媒体形态逐条罗列 + Parameters/Notes 镜像 --help」。token 不是被任务必需信息占用,而是被**重复表述**占用。按「同一份事实只写一次」(锚点 1合并到单一事实源后每条 load-bearing 信息仍恰好出现一次080 这类「读该 reference→发消息」的题读入 token 直接下降而行为不变。
- **不删能力**:每个 flagtext/markdown/content/image/file/video/audio/idempotency/dry-run/msg-type/video-cover/as、每条硬约束互斥、video-cover 必配、cwd-relative 路径、绝对路径拒绝、markdown 强制 post/无 title、msg-type 冲突校验)、三套 `<at>` 语法、content 各 msg_type 样例、Safety Constraints、identity+scope 映射——全部保留,只是从「重复 N 次/逐条罗列」变成「一处/代表性示例 + --help 指针」。
- **规范经验源**:依 optimization-playbook「reference 收敛到 gotcha-only不做 --help 镜像」——Parameters 全表/全形态 Commands 属 USAGE下沉到 `--help` 指针;保留的是 --help 表达不了的跨 flag 互斥、媒体路径安全、markdown→post 边界、@mention 按类型差异等 gotcha。annotation 标这些段为 R1可重构/下沉),符合处理方向;唯一 R3Safety原样保留。
## 预期效果
- **成功率**不退化。080唯一读该文件的题的发卡片链路依赖的是 `--content`/`interactive`、identity=bot、chat-id——全部保留选型表、content Format Reference、Safety、scope 都在。015/080 走通 bot 身份的判断由 SKILL.md + identity 段承载,本轮**没碰 SKILL.md**零误伤面。014 与本文件无关reach 不含 014
- **context分两层**
- (1) **静态字数差**16,407 → 6,399 chars-61.0%tiktoken cl100k 3,869 → 1,799 tok-53.5%diagnosis 报 ~5,365 tok 系另一 tokenizer/含注入开销;此处用 cl100k 自测,方向与幅度一致。)
- (2) **运行时 context 方向**:仅在**实读该 reference 的子集**生效——本轮即 080 一题,运行时读入下降约 50%+(该块占 080 visible 24.8%,预计 080 visible 降约 1213%。其余两题014/015不读该文件运行时 token **不变**(既不增也不减)。这是按需 reference不是常驻面不会影响未读它的题。
- **覆盖敞口**RC-2 子集仅 080 一题reach=0.333证据基数小。coverage 判该文件「密/overfit 低」,本轮 eval 可在 080 上裁真伪,但单题不可外推到「所有发消息任务」。建议后续补「读 messages-send.md 后用 --markdown / 媒体 / @mention」的 case 加厚子集。预期收益落在 **token 轴**080 visible 下降effect 轴维持不退化。
## 刻意没做什么(反 reward-hack / 反过拟合)
- 没硬编码任何评测题答案没删任何能力、flag、guardrail、身份/scope 说明;没碰 lark-im 以外文件也没把无关根因捆进本轮commit 仅 1 个文件)。
- **没碰 SKILL.mdRC-1**:尽管 reach=1.0 杠杆最大,但其剩余内容是驱动 015/080 bot 身份判断的承重 identity/约束diagnosis 标为 effect 风险点;在「保住成功率」硬门槛下不拿成功率换 token。
- **没补收窄/分页指引**015 的 22.5k chat-messages-list 黑洞):那是「增内容」,与降 token 目标方向相反diagnosis 已列为观察项、本轮不做。
- 本改动**不是按评测错误反推**的参数/路由拟合——是基于 annotation + content-taxonomy 的结构性去重,删的是重复表述而非按 080 的具体内容裁剪;真实价值在「任何读该 reference 的发消息任务都少读重复 token」080 只是当轮可验证的子集。
- 未发现需要 breakingT3才能根治的点本轮纯 T1 文档去重即可。
## 签名
- signature: 557349b40feb359bb791749a37571d59edb7e72e (commit 82a099fe 的 diff hash) tier: T1

View File

@@ -0,0 +1,11 @@
[
{
"round": 1,
"n": 3,
"pass_n": 0,
"cmd_fail_rate": 0.6,
"tool_calls": 26.333333333333332,
"duration_ms": 50189.0,
"token": 31997.0
}
]

View File

@@ -0,0 +1,43 @@
# Round 2 归因派工单parent=a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e模块未定由 candidate-writer 据诊断点名)
> **只读输入**——opt-attributor 读本文件,把诊断**另写** `diagnosis.md`(给 candidate-writer+ 逐题结构化 `attribution.json`(给 dashboard。**不要覆盖本文件**,留作派工单↔诊断的前后对比。
> 判分点只当「什么算挂」的锚,禁止照抄 grader 药方(已从派工单剔除)。
## 模块运行时可达性(选模块第一步的证据;要选须在 strategy.md 说明理由)
> reach=**实测**触达率(域主 SKILL.md 经 Skill 工具加载、reference 经 Read都从 trace 实测,没有恒在的面);判决集=实测∪预期触达。**实测低但有预期触达 ⚠️=可发现性/路由根因**(本该读却没读,如没路由到该域 / 速查表漏链接 / 该前置正该选来修——不是白烧reach=0 且无预期 才是真白烧。 **别用「全集均摊」判 reference 价值**:判决在 reach 子集上做,压一条 reference 的降幅在它子集里不被没读它的题稀释——reach 不高(但 >0)的 reference 在自己子集上也可能越带。
- `skills/lark-im/SKILL.md` → reach=1.0 [域主 skill·经 Skill 工具加载];判决集(实测∪预期): ['1', '2', '3']
- `skills/lark-im/references/lark-im-chat-create.md` → reach=0.667;判决集(实测∪预期): ['1', '3']
- `skills/lark-im/references/lark-im-messages-send.md` → reach=0.333;判决集(实测∪预期): ['3']
- (另 22 个 reference reach=0 且无预期触达,本轮无关,略)
## 逐轮诊断信号趋势(纯诊断,不进判决)
| 轮 | 题数 | PASS | 命令失败率 | 工具调用 | 耗时(ms) | token |
|---|---|---|---|---|---|---|
| R1 | 3 | 0 | 0.60 | 26 | 50189 | 31997 |
> 跨题均值,按轮排。**命令失败率、工具调用数是横切诊断信号,不是准入轴**(准入只走 效果/token/耗时)——用来判「上一轮那刀有没有把失败/轮次压下去」。工具调用数比 wall-clock 稳,可给噪声大的耗时轴当旁证。
### 1 [PASS] ctx=34270 (acc=274608) 43995ms tools=31
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✗ 使用当前用户身份创建名为「IM合作群」的群聊
证据: transcript 在展示授权二维码后结束,无任何 `lark-cli im +chat-create` 调用。执行停在 '授权完成后请告诉我,我会继续帮你创建群聊并发送消息',群聊未创建。
✗ 将傅一铭和傅二铭加入该群
证据: transcript 显示尝试搜索用户时遇到 `need_user_authorization` 错误,授权流程启动后中断。未获取到任何用户的 open_id无后续添加操作。
✗ 在该群发送文本消息「大家体验有问题随时沟通」,并返回可验证的 chat_id / message_id
证据: 群聊未创建,无 chat_id 可返回。transcript 无任何 `lark-cli im messages-send` 调用。
### 2 [PASS] ctx=47116 (acc=612048) 114310ms tools=49
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_015/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✓ 成功定位名为「fusanming_at_openclaw群」的群并获取最近包含「飞豆」关键字的消息
✓ 将筛选出的相关消息内容转发到「fusanming_at_需求测试群」
✓ 在「fusanming_at_需求测试群」中 @傅六铭 做知会,消息发送成功
### 3 [PASS] ctx=37942 (acc=251669) 45769ms tools=23
- session.jsonl: harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_080/0/session.jsonl [native]
- 判分点grader 的「什么算挂」oracle非药方:
✓ 使用用户身份创建一个名为「今晚吃什么」的群,预期返回 chat_id
✓ 创建一张飞书卡片,卡片内容包含「今天晚上吃什么」
✓ 将该卡片发送到新建群中,预期返回 message_id

View File

@@ -0,0 +1,59 @@
[
{
"case_id": "1",
"verdict": "PASS",
"verdict_note": "workorder=PASS聚合口径判分点证据 3/3 ✗,按判分点当实质 FAIL 处理",
"token": 34555,
"duration_ms": 37000,
"tool_calls": 31,
"cmd_attempts": 7,
"cmd_failures": 5,
"cmd_fail_rate": 0.71,
"discoverability_state": "无(失败命令全是跨域 contact + auth非 lark-imchat-create.md 调用前已读但未走到使用)",
"axis": "效果",
"root_cause": "沙箱 user 授权不可完成 + 跨域 lark-contact 命令依赖;无 lark-im 文档根因,本轮不改",
"token_hotspot": "运行时冗余清单常驻SKILL.md 3,456+ 按需 chat-create.md 3,062读了没用上lark-shared 3,751 与系统 Skill 列表注入 4,612 均不归因",
"token_reliability": "常驻静态SKILL.md/ 按需读取chat-create.md本题读了没用上",
"duration_hotspot": "多轮交互(查联系人→切 contact→失败→授权→qrcode 重试)+ 纯外部API延迟(部分不可归因)",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "效果侧无 lark-im 文档缺信息(环境+跨域token 侧 chat-create.md 把同组 flag 在 Commands/Usage Scenarios 重复演示、Common Errors 复述 validation 字符串,属可删冗余"
},
{
"case_id": "2",
"verdict": "PASS",
"verdict_note": "真 PASS判分点 3/3 ✓,全程 bot 身份无授权阻断",
"token": 54568,
"duration_ms": 125000,
"tool_calls": 49,
"cmd_attempts": 11,
"cmd_failures": 3,
"cmd_fail_rate": 0.27,
"discoverability_state": "① 从没读messages-search.md / chat-messages-list.md reach=0直接猜命令本题未读任何 lark-im reference",
"axis": "token",
"root_cause": "无过滤 +chat-messages-list --page-all 全量拉取 → 43.5KB 输出被 Read 整文件灌入 22,556 toktoken 大头非 lark-im doc。修它需补收窄/前置内容,与降 token 目标方向冲突,列观察项",
"token_hotspot": "工具返回原样输出block #19 单次 Read 22,556 tok / 51.5%,归「其他工具调用/返回」)",
"token_reliability": "单次输出(强依赖该群消息量,单题不可外推,非稳定常驻热点)",
"duration_hotspot": "多轮交互 + 重试messages-search 连环 exit2 → page-all → 大输出 → 多次本地 grep 抠数据)",
"duration_reliability": "耗时波动大单次运行不算数需多题或多次复现model calls 16 作旁证,明显高于 080",
"doc_fix_hint": "本题无 T1 可发力的 token 抓手(大头是单次工具输出,非 lark-im doc 常驻);缺的是大群消息查询的 server-side 收窄指引,但补它=增内容、与降 token 反向,不作本轮根因"
},
{
"case_id": "3",
"verdict": "PASS",
"verdict_note": "真 PASS判分点 3/3 ✓,主动选 bot 身份建群+发卡片均 ok:true零命令失败",
"token": 38009,
"duration_ms": 47000,
"tool_calls": 22,
"cmd_attempts": 3,
"cmd_failures": 0,
"cmd_fail_rate": 0.0,
"discoverability_state": "无无失败命令SKILL.md + chat-create.md + messages-send.md 全部状态③:调用前已读且用上)",
"axis": "token",
"root_cause": "读取 Skill 占 56.4%;本轮唯一干净 token 抓手 = chat-create.md 内部冗余(示例罗列 + 场景重复 + --help 镜像),从未被优化过",
"token_hotspot": "运行时冗余清单常驻 + 按需 referencechat-create.md 当前 2,336 raw可压 Commands/Usage Scenarios 重叠 + Common Errors validation 镜像trace 里 messages-send.md 5,365 是旧版round-2 已压到 2,006本轮不再可压",
"token_reliability": "按需读取chat-create.md reach=0.667,本题是其压缩收益唯一稳态兑现题)",
"duration_hotspot": "无离群(建群+发卡片正常串行,无重试、无写后回查)",
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
"doc_fix_hint": "chat-create.md 把同组 flag 在 Commands(12 例)+Usage Scenarios(3 场景)重复演示、Common Errors 多行复述 --help/报错本身就会吐的 validation 字符串属可删冗余232043 两步流 / --chat-mode topic 区分 / --owner 默认为载重红线,压缩中不可误删"
}
]

View File

@@ -0,0 +1,27 @@
{
"1": [
"auth login",
"auth qrcode",
"contact +search-user"
],
"3": [
"auth login",
"auth qrcode",
"auth status",
"im +chat-create",
"im +messages-send"
],
"2": [
"auth login",
"auth qrcode",
"auth status",
"im +chat-messages-list",
"im +chat-search",
"im +messages-mget",
"im +messages-search",
"im +messages-send",
"im messages forward",
"schema im.messages.forward",
"schema im.messages.search"
]
}

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