chore: import upstream snapshot with attribution
CI / results (push) Blocked by required conditions
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / e2e-live (push) Waiting to run
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / deadcode (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:54 +08:00
commit bf9395e022
2349 changed files with 588574 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT_DIR="$ROOT_DIR/.pkg-pr-new"
cd "$ROOT_DIR"
python3 scripts/fetch_meta.py
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR/bin" "$OUT_DIR/scripts"
VERSION="$(node -p "require('./package.json').version")"
DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
SHA="$(git rev-parse --short HEAD)"
LDFLAGS="-s -w -X github.com/larksuite/cli/internal/build.Version=${VERSION}-${SHA} -X github.com/larksuite/cli/internal/build.Date=${DATE}"
build_target() {
local goos="$1"
local goarch="$2"
local ext=""
if [[ "$goos" == "windows" ]]; then
ext=".exe"
fi
local output="$OUT_DIR/bin/lark-cli-${goos}-${goarch}${ext}"
echo "Building ${goos}/${goarch} -> ${output}"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags "$LDFLAGS" -o "$output" ./main.go
}
build_target darwin arm64
build_target linux amd64
build_target darwin amd64
build_target linux arm64
build_target linux riscv64
build_target windows amd64
build_target windows arm64
cat > "$OUT_DIR/scripts/run.js" <<'RUNJS'
#!/usr/bin/env node
const path = require("path");
const { execFileSync } = require("child_process");
const isWindows = process.platform === "win32";
const platformMap = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
// TODO: Keep broad platform mapping for now; with pkg.pr.new 20MB limit we only ship a subset of binaries.
// Track upstream progress before tightening runtime handling: https://github.com/stackblitz-labs/pkg.pr.new/pull/484
const archMap = {
x64: "amd64",
arm64: "arm64",
riscv64: "riscv64",
};
const platform = platformMap[process.platform];
const arch = archMap[process.arch];
if (!platform || !arch) {
console.error(`Unsupported platform: ${process.platform}-${process.arch}`);
process.exit(1);
}
const ext = isWindows ? ".exe" : "";
const binary = path.join(__dirname, "..", "bin", `lark-cli-${platform}-${arch}${ext}`);
try {
execFileSync(binary, process.argv.slice(2), { stdio: "inherit" });
} catch (err) {
process.exit(err.status || 1);
}
RUNJS
chmod +x "$OUT_DIR/scripts/run.js"
cat > "$OUT_DIR/package.json" <<EOF_JSON
{
"name": "@larksuite/cli",
"version": "${VERSION}-pr.${SHA}",
"description": "The official CLI for Lark/Feishu open platform (PR preview build)",
"bin": {
"lark-cli": "scripts/run.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/larksuite/cli.git"
},
"license": "MIT",
"files": [
"bin",
"scripts/run.js",
"CHANGELOG.md",
"LICENSE"
]
}
EOF_JSON
cp CHANGELOG.md "$OUT_DIR/CHANGELOG.md"
cp LICENSE "$OUT_DIR/LICENSE"
echo "Prepared pkg.pr.new package at $OUT_DIR"
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
#
# check-doc-tokens.sh
#
# Scans skill reference docs for token-like values that look realistic but
# are not using the required placeholder format (*_EXAMPLE_TOKEN or similar).
#
# Real token patterns (Lark API) often look like:
# wikcnXXXXXXXXX doccnXXXXXXX shtcnXXX fldcnXXX ou_XXXX cli_XXXX
#
# Docs MUST use clearly fake placeholders, e.g.:
# wikcn_EXAMPLE_TOKEN doccn_EXAMPLE_TOKEN <space_id> your_token_here
#
# If this check fails, replace the realistic-looking value with a placeholder
# like `wikcn_EXAMPLE_TOKEN` so gitleaks CI won't flag it as a real secret.
set -euo pipefail
SKILLS_DIR="${1:-skills}"
ERRORS=0
# Patterns that indicate a realistic-looking Lark token value.
# Three forms are detected:
# 1. JSON-style quoted strings: "field": "token_value"
# 2. Markdown backtick spans: `token_value`
# 3. Bare tokens: --flag wikcnABC123 (e.g. inside fenced code blocks)
#
# Token prefixes used by Lark Open Platform:
# wikcn doccn docx shtcn bascn fldcn vewcn tbln ou_ cli_ obcn flec
#
# Excluded (clearly fake, matched by PLACEHOLDER_RE below):
# - Values containing EXAMPLE / _TOKEN / XXXX / your_ / _here
# - Angle-bracket placeholders <your_token>
# Require at least one digit in the suffix — real API tokens are always alphanumeric
# with digits. Pure-letter suffixes (e.g. ou_manager, ou_director) are clearly fake names.
PREFIXES='(wikcn|doccn|docx[a-z]|shtcn|bascn|fldcn|vewcn|tbln|obcn|flec|ou_|cli_)'
TOKEN_BODY="${PREFIXES}"'[A-Za-z0-9]*[0-9][A-Za-z0-9]{3,}'
REALISTIC_TOKEN_RE="\"${TOKEN_BODY}\"|\`${TOKEN_BODY}\`|\\b${TOKEN_BODY}\\b"
PLACEHOLDER_RE='(EXAMPLE|_TOKEN|XXXX|xxxx|<|>|your_|_here)'
while IFS= read -r -d '' file; do
# grep returns exit 1 when no match — use || true to avoid set -e killing us
# Then filter out values that are clearly placeholders (EXAMPLE, XXXX, etc.)
matches=$(grep -nEo "$REALISTIC_TOKEN_RE" "$file" 2>/dev/null | grep -vE "$PLACEHOLDER_RE" || true)
if [[ -n "$matches" ]]; then
echo ""
echo "$file"
echo " Contains realistic-looking token values that may trigger gitleaks:"
while IFS= read -r line; do
echo " $line"
done <<< "$matches"
echo " → Replace with a placeholder, e.g.: wikcn_EXAMPLE_TOKEN, doccn_EXAMPLE_TOKEN"
ERRORS=$((ERRORS + 1))
fi
done < <(find "$SKILLS_DIR" -path "*/references/*.md" -print0)
if [[ $ERRORS -gt 0 ]]; then
echo ""
echo "❌ check-doc-tokens: $ERRORS file(s) contain realistic token values in reference docs."
echo " Use _EXAMPLE_TOKEN placeholders to avoid false positives in gitleaks CI."
exit 1
else
echo "✅ check-doc-tokens: all reference docs use safe placeholder tokens."
fi
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# §12.3: forward rule — any errs/ wire-shape change MUST be paired
# with a skills/ grep sweep in the same PR.
set -euo pipefail
PATTERN='"type"\s*:\s*"(auth_error|api_error|infra_error|missing_scope|command_denied|external_provider)"'
if git grep -E "$PATTERN" skills/ >/dev/null 2>&1; then
echo "[WIRE-VOCAB-DRIFT] skills/ contains legacy wire strings — see spec §12.3" >&2
git grep -nE "$PATTERN" skills/ >&2
exit 1
fi
echo "skill wire-vocab clean."
+229
View File
@@ -0,0 +1,229 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("fs");
const {
deleteQualitySummaries,
inlineCode,
markdownText,
publishQualitySummary,
} = require("./pr-quality-summary.js");
function readJSON(path) {
try {
const raw = fs.readFileSync(path, "utf8");
const value = JSON.parse(raw);
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
} catch {
return {};
}
}
function verifiedPublishTarget() {
const pr = Number(process.env.CI_QUALITY_SUMMARY_PR_NUMBER || 0);
if (!Number.isInteger(pr) || pr <= 0) {
throw new Error("missing verified PR quality summary pull request number");
}
const headSha = process.env.CI_QUALITY_SUMMARY_HEAD_SHA || "";
if (!/^[a-f0-9]{40}$/i.test(headSha)) {
throw new Error("missing verified PR quality summary head sha");
}
const baseSha = process.env.CI_QUALITY_SUMMARY_BASE_SHA || "";
if (!/^[a-f0-9]{40}$/i.test(baseSha)) {
throw new Error("missing verified PR quality summary base sha");
}
const runId = process.env.CI_QUALITY_SUMMARY_RUN_ID || "";
if (!/^\d+$/.test(runId)) {
throw new Error("missing verified PR quality summary workflow run id");
}
return { pr, headSha, baseSha, runId };
}
async function publishTargetStillCurrent(github, context, core, target, phase = "publishing") {
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: target.pr,
});
if (pr.state !== "open") {
core.notice(`PR quality summary skipped: PR is no longer open before ${phase}`);
return false;
}
if (pr.head.sha !== target.headSha) {
core.notice(`PR quality summary skipped: PR head changed before ${phase}`);
return false;
}
if (pr.base.sha !== target.baseSha) {
core.notice(`PR quality summary skipped: PR base changed before ${phase}`);
return false;
}
if (pr.base.repo.id !== context.payload.repository.id) {
throw new Error("PR base repo mismatch before PR quality summary publishing");
}
return true;
}
function isFailedJob(job) {
const conclusion = String(job?.conclusion || "").toLowerCase();
return conclusion === "failure" ||
conclusion === "cancelled" ||
conclusion === "timed_out" ||
conclusion === "action_required";
}
function failedJobs(jobs) {
return (Array.isArray(jobs) ? jobs : []).filter(isFailedJob);
}
function jobName(job) {
return String(job?.name || job?.job_name || "unknown");
}
function jobConclusion(job) {
return String(job?.conclusion || job?.status || "unknown");
}
function jobDetails(job) {
const url = String(job?.html_url || "");
return url ? `[details](${url})` : "details unavailable";
}
function diagnosticLocation(diagnostic) {
const file = String(diagnostic?.file || "");
const line = Number(diagnostic?.line || 0);
if (file && Number.isInteger(line) && line > 0) {
return `${file}:${line}`;
}
const command = String(diagnostic?.command_path || "");
if (command) {
return command;
}
return "summary-only";
}
function rejectDiagnostics(facts) {
return (Array.isArray(facts?.diagnostics) ? facts.diagnostics : [])
.filter((diagnostic) => String(diagnostic?.action || "").toUpperCase() === "REJECT");
}
function buildCIQualitySummary({ run, jobs, facts = {}, artifactError = "" }) {
const failed = failedJobs(jobs);
const runConclusion = String(run?.conclusion || "");
if (failed.length === 0 && runConclusion === "success") {
return "";
}
const lines = [
"## PR Quality Summary",
"",
"CI did not complete successfully. Use the failed check links below to decide whether this PR needs a code change or a rerun.",
"",
];
if (failed.length > 0) {
lines.push("### Failed checks", "");
for (const job of failed) {
lines.push(`- **${markdownText(jobName(job))}** — ${markdownText(jobConclusion(job))}${jobDetails(job)}`);
}
lines.push("");
} else {
lines.push(`### CI status`, "", `- Workflow conclusion: ${markdownText(runConclusion || "unknown")}.`, "");
}
const deterministicFailed = failed.some((job) => jobName(job) === "deterministic-gate");
if (deterministicFailed) {
const diagnostics = rejectDiagnostics(facts);
lines.push("### deterministic-gate", "");
if (diagnostics.length === 0) {
const reason = artifactError || "quality-gate facts did not include a blocking diagnostic for this failed run";
lines.push(`- System issue: deterministic-gate failed, but quality-gate facts were unavailable. ${markdownText(reason)}`);
} else {
for (const diagnostic of diagnostics.slice(0, 20)) {
const parts = [
`**${markdownText(diagnostic?.rule || "quality-gate")}**`,
inlineCode(diagnosticLocation(diagnostic)),
markdownText(diagnostic?.message || ""),
];
if (diagnostic?.suggestion) {
parts.push(`Action: ${markdownText(diagnostic.suggestion)}`);
}
lines.push(`- ${parts.filter(Boolean).join(" — ")}`);
}
if (diagnostics.length > 20) {
lines.push(`- ${diagnostics.length - 20} additional deterministic findings are available in the check logs.`);
}
}
lines.push("");
}
return lines.join("\n");
}
async function listWorkflowRunJobs(github, context, runId) {
return github.paginate(github.rest.actions.listJobsForWorkflowRun, {
owner: context.repo.owner,
repo: context.repo.repo,
run_id: Number(runId),
per_page: 100,
});
}
async function publish({ github, context, core }) {
const run = context.payload.workflow_run;
if (!run || run.event !== "pull_request") {
core.notice("PR quality summary skipped: workflow_run is not a pull_request run");
return;
}
const target = verifiedPublishTarget();
if (!(await publishTargetStillCurrent(github, context, core, target))) {
return;
}
const jobs = await listWorkflowRunJobs(github, context, target.runId);
const facts = readJSON("facts.json");
const artifactError = process.env.CI_QUALITY_SUMMARY_ARTIFACT_ERROR || "";
const markdown = buildCIQualitySummary({ run, jobs, facts, artifactError });
try {
if (!markdown) {
if (!(await publishTargetStillCurrent(github, context, core, target, "summary cleanup"))) {
return;
}
await deleteQualitySummaries({
github,
context,
pr: target.pr,
target,
beforeWrite: (action) => publishTargetStillCurrent(github, context, core, target, `summary ${action}`),
});
return;
}
if (!(await publishTargetStillCurrent(github, context, core, target, "summary comment"))) {
return;
}
await publishQualitySummary({
github,
context,
pr: target.pr,
target,
markdown,
beforeWrite: (action) => publishTargetStillCurrent(github, context, core, target, `summary comment ${action}`),
});
} catch (err) {
core.warning(`PR quality summary comment was not published: ${err.message}`);
if (typeof core.setFailed === "function") {
core.setFailed(`PR quality summary comment was not published: ${err.message}`);
} else {
throw err;
}
}
}
module.exports = {
buildCIQualitySummary,
failedJobs,
isFailedJob,
publish,
verifiedPublishTarget,
};
+368
View File
@@ -0,0 +1,368 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
buildCIQualitySummary,
failedJobs,
isFailedJob,
publish,
verifiedPublishTarget,
} = require("./ci-quality-summary-publish.js");
describe("ci-quality-summary-publish", () => {
it("classifies failed CI job conclusions", () => {
assert.equal(isFailedJob({ conclusion: "failure" }), true);
assert.equal(isFailedJob({ conclusion: "cancelled" }), true);
assert.equal(isFailedJob({ conclusion: "timed_out" }), true);
assert.equal(isFailedJob({ conclusion: "success" }), false);
assert.deepEqual(failedJobs([
{ name: "unit-test", conclusion: "success" },
{ name: "lint", conclusion: "failure" },
]).map((job) => job.name), ["lint"]);
});
it("builds no summary for successful CI with no failed jobs", () => {
const markdown = buildCIQualitySummary({
run: { conclusion: "success" },
jobs: [{ name: "results", conclusion: "success" }],
});
assert.equal(markdown, "");
});
it("builds a regular CI failure summary with check links", () => {
const markdown = buildCIQualitySummary({
run: { conclusion: "failure" },
jobs: [
{ name: "unit-test", conclusion: "failure", html_url: "https://github.example/jobs/1" },
{ name: "results", conclusion: "failure", html_url: "https://github.example/jobs/2" },
],
});
assert.match(markdown, /## PR Quality Summary/);
assert.match(markdown, /### Failed checks/);
assert.match(markdown, /\*\*unit-test\*\* — failure/);
assert.match(markdown, /\[details\]\(https:\/\/github.example\/jobs\/1\)/);
assert.doesNotMatch(markdown, /### deterministic-gate/);
});
it("adds deterministic diagnostics when deterministic-gate fails with facts", () => {
const markdown = buildCIQualitySummary({
run: { conclusion: "failure" },
jobs: [{ name: "deterministic-gate", conclusion: "failure", html_url: "https://github.example/jobs/dg" }],
facts: {
diagnostics: [{
rule: "error_hint",
action: "REJECT",
file: "shortcuts/contact/contact_get_user.go",
line: 30,
message: "Boundary invalid-argument error lacks an actionable recovery step.",
suggestion: "Update the hint with supported --user-id-type values.",
}],
},
});
assert.match(markdown, /### deterministic-gate/);
assert.match(markdown, /error\\_hint/);
assert.match(markdown, /shortcuts\/contact\/contact_get_user.go:30/);
assert.match(markdown, /Action: Update the hint/);
});
it("reports deterministic facts as a system issue when artifact data is missing", () => {
const markdown = buildCIQualitySummary({
run: { conclusion: "failure" },
jobs: [{ name: "deterministic-gate", conclusion: "failure" }],
facts: {},
artifactError: "quality-gate facts artifact expired",
});
assert.match(markdown, /System issue/);
assert.match(markdown, /quality-gate facts artifact expired/);
});
it("requires verifier-provided publish target", () => {
const env = saveEnv();
try {
delete process.env.CI_QUALITY_SUMMARY_PR_NUMBER;
assert.throws(() => verifiedPublishTarget(), /missing verified PR quality summary pull request number/);
} finally {
restoreEnv(env);
}
});
it("deletes an existing summary when CI succeeds", async () => {
await withPublishTempDir(async ({ calls }) => {
await publish({
github: fakeGithub(calls, {
jobs: [{ name: "results", conclusion: "success" }],
issueComments: [{
id: 99,
user: { type: "Bot" },
body: "<!-- lark-cli-pr-quality-summary head=old -->",
}],
}),
context: workflowRunContext({ conclusion: "success" }),
core: silentCore(calls),
});
assert.equal(calls.comments.length, 0);
assert.deepEqual(calls.deletedComments.map((c) => c.comment_id), [99]);
});
});
it("publishes a summary when CI has failed jobs", async () => {
await withPublishTempDir(async ({ calls }) => {
await publish({
github: fakeGithub(calls, {
jobs: [{ name: "unit-test", conclusion: "failure", html_url: "https://github.example/jobs/1" }],
}),
context: workflowRunContext({ conclusion: "failure" }),
core: silentCore(calls),
});
assert.equal(calls.comments.length, 1);
assert.equal(calls.comments[0].issue_number, 42);
assert.match(calls.comments[0].body, /^<!-- lark-cli-pr-quality-summary /);
assert.match(calls.comments[0].body, /\*\*unit-test\*\*/);
});
});
it("does not publish a summary when the PR head changes before comment creation", async () => {
await withPublishTempDir(async ({ calls }) => {
await publish({
github: fakeGithub(calls, {
jobs: [{ name: "unit-test", conclusion: "failure", html_url: "https://github.example/jobs/1" }],
pullResponses: [
currentPullResponse(),
currentPullResponse({ headSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }),
],
}),
context: workflowRunContext({ conclusion: "failure" }),
core: silentCore(calls),
});
assert.equal(calls.comments.length, 0);
assert.match(calls.notices.join("\n"), /PR head changed/);
});
});
it("does not publish a summary when the PR closes before comment creation", async () => {
await withPublishTempDir(async ({ calls }) => {
await publish({
github: fakeGithub(calls, {
jobs: [{ name: "unit-test", conclusion: "failure", html_url: "https://github.example/jobs/1" }],
pullResponses: [
currentPullResponse(),
currentPullResponse({ state: "closed" }),
],
}),
context: workflowRunContext({ conclusion: "failure" }),
core: silentCore(calls),
});
assert.equal(calls.comments.length, 0);
assert.match(calls.notices.join("\n"), /PR is no longer open/);
});
});
it("does not delete an existing summary when the PR base changes before cleanup", async () => {
await withPublishTempDir(async ({ calls }) => {
await publish({
github: fakeGithub(calls, {
jobs: [{ name: "results", conclusion: "success" }],
issueComments: [{
id: 99,
user: { type: "Bot" },
body: "<!-- lark-cli-pr-quality-summary head=old -->",
}],
pullResponses: [
currentPullResponse(),
currentPullResponse({ baseSha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }),
],
}),
context: workflowRunContext({ conclusion: "success" }),
core: silentCore(calls),
});
assert.equal(calls.deletedComments.length, 0);
assert.match(calls.notices.join("\n"), /PR base changed/);
});
});
it("publishes deterministic diagnostics from facts.json", async () => {
await withPublishTempDir(async ({ calls }) => {
fs.writeFileSync("facts.json", JSON.stringify({
diagnostics: [{
rule: "skill_reference",
action: "REJECT",
file: "skills/lark-doc/SKILL.md",
line: 9,
message: "Invalid command reference.",
suggestion: "Use docs +fetch.",
}],
}), "utf8");
await publish({
github: fakeGithub(calls, {
jobs: [{ name: "deterministic-gate", conclusion: "failure" }],
}),
context: workflowRunContext({ conclusion: "failure" }),
core: silentCore(calls),
});
assert.match(calls.comments[0].body, /### deterministic-gate/);
assert.match(calls.comments[0].body, /skills\/lark-doc\/SKILL\.md:9/);
assert.match(calls.comments[0].body, /Use docs \+fetch/);
});
});
it("fails visibly when a required CI summary cannot be published", async () => {
await withPublishTempDir(async ({ calls }) => {
await publish({
github: fakeGithub(calls, {
failComments: true,
jobs: [{ name: "unit-test", conclusion: "failure" }],
}),
context: workflowRunContext({ conclusion: "failure" }),
core: silentCore(calls),
});
assert.equal(calls.comments.length, 0);
assert.match(calls.warnings[0], /PR quality summary comment was not published/);
assert.match(calls.failures[0], /PR quality summary comment was not published/);
});
});
});
function saveEnv() {
return {
CI_QUALITY_SUMMARY_PR_NUMBER: process.env.CI_QUALITY_SUMMARY_PR_NUMBER,
CI_QUALITY_SUMMARY_HEAD_SHA: process.env.CI_QUALITY_SUMMARY_HEAD_SHA,
CI_QUALITY_SUMMARY_BASE_SHA: process.env.CI_QUALITY_SUMMARY_BASE_SHA,
CI_QUALITY_SUMMARY_RUN_ID: process.env.CI_QUALITY_SUMMARY_RUN_ID,
CI_QUALITY_SUMMARY_ARTIFACT_ERROR: process.env.CI_QUALITY_SUMMARY_ARTIFACT_ERROR,
};
}
function restoreEnv(env) {
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
async function withPublishTempDir(fn) {
const env = saveEnv();
const cwd = process.cwd();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-quality-summary-"));
const calls = { comments: [], deletedComments: [], failures: [], notices: [], order: [], warnings: [] };
try {
process.chdir(dir);
process.env.CI_QUALITY_SUMMARY_PR_NUMBER = "42";
process.env.CI_QUALITY_SUMMARY_HEAD_SHA = "0123456789abcdef0123456789abcdef01234567";
process.env.CI_QUALITY_SUMMARY_BASE_SHA = "fedcba9876543210fedcba9876543210fedcba98";
process.env.CI_QUALITY_SUMMARY_RUN_ID = "123456";
await fn({ calls, dir });
} finally {
process.chdir(cwd);
restoreEnv(env);
}
}
function workflowRunContext({ conclusion }) {
return {
repo: { owner: "larksuite", repo: "cli" },
payload: {
repository: { id: 123 },
workflow_run: {
id: 123456,
event: "pull_request",
conclusion,
},
},
};
}
function silentCore(calls) {
return {
notice(message) {
calls.notices.push(message);
},
warning(message) {
calls.warnings.push(message);
},
setFailed(message) {
calls.failures.push(message);
},
};
}
function fakeGithub(calls, options = {}) {
const pullResponses = Array.isArray(options.pullResponses) ? [...options.pullResponses] : null;
const api = {
paginate: async (endpoint) => {
if (options.failComments && endpoint === api.rest.issues.listComments) {
throw new Error("comment API unavailable");
}
if (endpoint === api.rest.actions.listJobsForWorkflowRun) {
return options.jobs || [];
}
if (endpoint === api.rest.issues.listComments) {
return options.issueComments || [];
}
return [];
},
rest: {
actions: {
listJobsForWorkflowRun() {},
},
issues: {
listComments() {},
createComment: async (args) => {
if (options.failComments) {
throw new Error("comment API unavailable");
}
calls.comments.push(args);
calls.order.push("comment");
},
updateComment: async (args) => {
if (options.failComments) {
throw new Error("comment API unavailable");
}
calls.comments.push(args);
calls.order.push("comment");
},
deleteComment: async (args) => {
calls.deletedComments.push(args);
calls.order.push("comment-delete");
},
},
pulls: {
get: async () => pullResponses && pullResponses.length > 0 ? pullResponses.shift() : currentPullResponse(),
},
},
};
return api;
}
function currentPullResponse(overrides = {}) {
return {
data: {
state: overrides.state || "open",
head: { sha: overrides.headSha || process.env.CI_QUALITY_SUMMARY_HEAD_SHA },
base: {
sha: overrides.baseSha || process.env.CI_QUALITY_SUMMARY_BASE_SHA,
repo: { id: 123 },
},
},
};
}
+431
View File
@@ -0,0 +1,431 @@
#!/usr/bin/env bash
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
set -euo pipefail
workflow=".github/workflows/ci.yml"
job_section() {
local job="$1"
awk -v job="$job" '
$0 == " " job ":" { in_job = 1; print; next }
in_job && /^ [A-Za-z0-9_-]+:/ { exit }
in_job { print }
' "$workflow"
}
workflow_permissions="$(awk '
/^permissions:/ { in_permissions = 1; print; next }
in_permissions && /^[^[:space:]]/ { exit }
in_permissions { print }
' "$workflow")"
fast_gate_section="$(job_section fast-gate)"
unit_test_section="$(job_section unit-test)"
lint_section="$(awk '
/^ lint:/ { in_job = 1 }
in_job { print }
/^ script-test:/ { exit }
' "$workflow")"
script_test_section="$(job_section script-test)"
deterministic_section="$(awk '
/^ deterministic-gate:/ { in_job = 1 }
in_job { print }
/^ coverage:/ { exit }
' "$workflow")"
coverage_job_section="$(job_section coverage)"
deadcode_section="$(job_section deadcode)"
dry_run_section="$(job_section e2e-dry-run)"
section="$(awk '
/^ e2e-live:/ { in_job = 1 }
in_job { print }
/^ security:/ { exit }
' "$workflow")"
security_section="$(job_section security)"
license_header_section="$(job_section license-header)"
results_section="$(awk '
/^ results:/ { in_job = 1 }
in_job { print }
' "$workflow")"
fork_safe_guard="github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork"
for denied_permission in "checks: write" "pull-requests: write" "issues: write"; do
if grep -Eq "^[[:space:]]*${denied_permission}$" <<<"$workflow_permissions"; then
echo "CI workflow must not grant ${denied_permission} at the workflow level" >&2
exit 1
fi
done
if ! grep -Fq "contents: read" <<<"$workflow_permissions" || ! grep -Fq "actions: read" <<<"$workflow_permissions"; then
echo "CI workflow should keep only read permissions at the workflow level"
exit 1
fi
if ! grep -Fq "deterministic-gate:" <<<"$deterministic_section"; then
echo "CI should expose deterministic-gate as a standalone job"
exit 1
fi
if grep -Fq "make quality-gate" <<<"$lint_section"; then
echo "lint job should not run deterministic quality gate"
exit 1
fi
if ! grep -Fq "needs: fast-gate" <<<"$deterministic_section"; then
echo "deterministic-gate should depend on fast-gate"
exit 1
fi
if ! grep -Fq "permissions:" <<<"$deterministic_section"; then
echo "deterministic-gate should define job-level permissions"
exit 1
fi
if ! grep -Fq "contents: read" <<<"$deterministic_section"; then
echo "deterministic-gate should only need read access to repository contents"
exit 1
fi
if ! grep -Fq "actions: read" <<<"$deterministic_section"; then
echo "deterministic-gate should keep actions access read-only"
exit 1
fi
if grep -Fq "checks: write" <<<"$deterministic_section"; then
echo "deterministic-gate should not inherit check write permission"
exit 1
fi
if grep -Fq "pull-requests: write" <<<"$deterministic_section"; then
echo "deterministic-gate should not inherit pull request write permission"
exit 1
fi
if grep -Fq '${{ secrets.' <<<"$deterministic_section"; then
echo "deterministic-gate must not reference secrets"
exit 1
fi
if ! grep -Fq "Run CLI deterministic gate" <<<"$deterministic_section"; then
echo "deterministic-gate should run the CLI deterministic gate step"
exit 1
fi
if ! grep -Fq "make quality-gate" <<<"$deterministic_section"; then
echo "deterministic-gate should invoke make quality-gate"
exit 1
fi
if ! grep -Fq "Write public content metadata" <<<"$deterministic_section"; then
echo "deterministic-gate should write PR title/body metadata before quality-gate"
exit 1
fi
if ! grep -Fq "types: [opened, synchronize, reopened, edited]" "$workflow"; then
echo "CI pull_request trigger should include edited so PR title/body changes are rescanned"
exit 1
fi
if ! grep -Fq "script-test:" <<<"$script_test_section"; then
echo "CI should run make script-test so workflow and publisher contract tests are not local-only"
exit 1
fi
if ! grep -Fq "make script-test" <<<"$script_test_section"; then
echo "script-test job should invoke make script-test"
exit 1
fi
if ! grep -Fq "actions/setup-node" <<<"$script_test_section"; then
echo "script-test job should install Node for JavaScript workflow tests"
exit 1
fi
if grep -Fq '${{ secrets.' <<<"$script_test_section"; then
echo "script-test must not reference secrets"
exit 1
fi
if grep -Fq "metadata-gate:" "$workflow"; then
echo "metadata-gate should not run alongside deterministic-gate because both would upload the same facts artifact"
exit 1
fi
if grep -Fq "github.event.action != 'edited'" <<<"$fast_gate_section"; then
echo "fast-gate must run on pull_request edited events so title/body edits cannot replace failed CI with a light success"
exit 1
fi
for full_job in \
"$unit_test_section" \
"$lint_section" \
"$script_test_section" \
"$deterministic_section" \
"$coverage_job_section" \
"$dry_run_section" \
"$security_section"; do
if grep -Fq "github.event.action != 'edited'" <<<"$full_job"; then
echo "full CI jobs must run on pull_request edited events; do not skip title/body-only edits"
exit 1
fi
done
for pull_request_job in "$deadcode_section" "$license_header_section"; do
if grep -Fq "github.event.action != 'edited'" <<<"$pull_request_job"; then
echo "pull_request-only CI jobs must run on edited events"
exit 1
fi
done
if grep -Fq '${{ secrets.' <<<"$deterministic_section"; then
echo "deterministic-gate must not reference secrets"
exit 1
fi
if ! grep -Fq "PUBLIC_CONTENT_METADATA=" <<<"$deterministic_section"; then
echo "deterministic-gate should pass public content metadata into make quality-gate"
exit 1
fi
if ! grep -Fq "PR_BRANCH:" <<<"$deterministic_section"; then
echo "deterministic-gate should pass the pull request branch into public content metadata"
exit 1
fi
if ! grep -Fq "name: quality-gate-facts-\${{ github.event.pull_request.base.sha }}-\${{ github.event.pull_request.head.sha }}" <<<"$deterministic_section"; then
echo "deterministic-gate should upload base/head-bound quality-gate-facts for semantic review"
exit 1
fi
if ! grep -Fq "needs: [unit-test, lint, script-test, deterministic-gate]" "$workflow"; then
echo "E2E jobs should wait for script-test and deterministic-gate"
exit 1
fi
if ! grep -Fq "script-test" <<<"$results_section"; then
echo "results job should include script-test"
exit 1
fi
if ! grep -Fq "deterministic-gate" <<<"$results_section"; then
echo "results job should include deterministic-gate"
exit 1
fi
if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
echo "e2e-live should run on push and same-repository pull_request, but skip fork pull_request"
exit 1
fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
! grep -Fq "id: e2e_domains" <<<"$dry_run_section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$dry_run_section"; then
echo "e2e-dry-run should resolve changed-file CLI E2E domains before running tests"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$dry_run_section" ||
! grep -Fq 'echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$dry_run_section"; then
echo "e2e-dry-run should pass dynamic domain output through env before shell use"
exit 1
fi
if ! grep -Fq "E2E_DRY_ROOT_PACKAGE: \${{ steps.e2e_domains.outputs.dry_root_package }}" <<<"$dry_run_section" ||
! grep -Fq 'go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"' <<<"$dry_run_section"; then
echo "e2e-dry-run should run the root CLI E2E harness package without the DryRun/Regression filter"
exit 1
fi
if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
echo "e2e-dry-run should explicitly skip when domain mode is skip"
exit 1
fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
! grep -Fq "id: e2e_domains" <<<"$section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
echo "e2e-live should use resolved live_packages instead of always running the full suite"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
echo "e2e-live should pass dynamic domain output through env before shell use"
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$dry_run_section"; then
echo "e2e-dry-run should skip building lark-cli when domain mode is skip"
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should skip building lark-cli when domain mode is skip"
exit 1
fi
if ! grep -Fq "permissions:" <<<"$section" ||
! grep -Fq "contents: read" <<<"$section" ||
! grep -Fq "checks: write" <<<"$section"; then
echo "e2e-live should grant only the job-level permissions needed to publish test reports"
exit 1
fi
if grep -Fq "pull-requests: write" <<<"$section" || grep -Fq "issues: write" <<<"$section"; then
echo "e2e-live should not grant pull request or issue write permission"
exit 1
fi
if grep -Fq "live_e2e_credentials" <<<"$section" || grep -Fq "configured=false" <<<"$section"; then
echo "e2e-live should fail, not silently skip, when required credentials are unavailable on eligible runs"
exit 1
fi
if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET" <<<"$section"; then
echo "e2e-live should make missing bot credentials a visible configuration failure on eligible runs"
exit 1
fi
if ! awk '
/^ - name: Configure bot credentials/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should only configure bot credentials when domain mode is not skip"
exit 1
fi
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
exit 1
fi
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
exit 1
fi
if grep -Fq "continue-on-error: true" <<<"$section"; then
echo "e2e-live report publishing should use explicit checks write permission instead of hiding publish failures"
exit 1
fi
coverage_step="$(awk '
/^ - name: Upload coverage to Codecov/ { in_step = 1 }
in_step { print }
in_step && /^ - name: Check coverage threshold/ { exit }
' "$workflow")"
if grep -Fq '${{ secrets.CODECOV_TOKEN }}' <<<"$coverage_step" &&
! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$coverage_step"; then
echo "Codecov token should be available on push and same-repository pull_request, but not fork pull_request" >&2
exit 1
fi
if grep -Fq '${{ secrets.' <<<"$section" &&
! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
echo "live E2E secrets should be available on push and same-repository pull_request, but not fork pull_request" >&2
exit 1
fi
if ! awk -v guard="$fork_safe_guard" '
/^ [A-Za-z0-9_-]+:/ {
job_if = "";
step_if = "";
}
/^ if:/ {
job_if = $0;
}
/^ - (name|uses):/ {
step_if = "";
}
/^ if:/ {
step_if = $0;
}
/\$\{\{ secrets\./ {
if (index(job_if, guard) || index(step_if, guard)) {
next;
}
printf("secret reference at %s:%d must be guarded away from pull_request runs\n", FILENAME, FNR) > "/dev/stderr";
bad = 1;
}
END { exit bad ? 1 : 0 }
' "$workflow"; then
exit 1
fi
make_output="$(QUALITY_GATE_CHANGED_FROM= make -n quality-gate)"
if grep -Fq -- "--changed-from \\" <<<"$make_output"; then
echo "quality-gate should resolve an empty QUALITY_GATE_CHANGED_FROM before passing --changed-from"
exit 1
fi
if ! grep -Fq "go run ./internal/qualitygate/cmd/manifest-export" <<<"$make_output"; then
echo "quality-gate should generate command manifests through manifest-export"
exit 1
fi
if ! grep -Fq -- "--public-content-metadata .tmp/quality-gate/public-content-metadata.json" <<<"$make_output"; then
echo "quality-gate check should consume public content metadata"
exit 1
fi
if ! grep -Fq -- "--manifest .tmp/quality-gate/command-manifest.json" <<<"$make_output" ||
! grep -Fq -- "--command-index .tmp/quality-gate/command-index.json" <<<"$make_output"; then
echo "quality-gate check should consume both exported command snapshots"
exit 1
fi
if ! awk '
function finish_upload() {
if (!in_upload) {
return;
}
uploads++;
if (path != ".tmp/quality-gate/facts.json") {
printf("deterministic-gate upload-artifact path must be .tmp/quality-gate/facts.json, got %s\n", path) > "/dev/stderr";
bad = 1;
}
in_upload = 0;
path = "";
}
/^ - (name|uses):/ {
finish_upload();
}
/uses: actions\/upload-artifact@/ {
in_upload = 1;
}
in_upload && /^[[:space:]]*path:/ {
path = $0;
sub(/^[[:space:]]*path:[[:space:]]*/, "", path);
}
END {
finish_upload();
if (uploads == 0) {
print "deterministic-gate should upload quality gate facts" > "/dev/stderr";
bad = 1;
}
exit bad ? 1 : 0;
}
' <<<"$deterministic_section"; then
exit 1
fi
+54
View File
@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const DOMAIN_MAP_PATH = path.join(__dirname, "domain-map.json");
const domainMap = JSON.parse(fs.readFileSync(DOMAIN_MAP_PATH, "utf8"));
function normalizeRepoPath(input) {
return String(input || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
}
const pathMappingsBySpecificity = (domainMap.pathMappings || [])
.map((entry) => ({ ...entry, prefix: normalizeRepoPath(entry.prefix) }))
.sort((a, b) => b.prefix.length - a.prefix.length);
function findPathMapping(filePath) {
const normalized = normalizeRepoPath(filePath);
return pathMappingsBySpecificity.find((entry) => normalized.startsWith(entry.prefix));
}
function labelDomainsForPath(filePath) {
const mapping = findPathMapping(filePath);
return mapping ? [...(mapping.labelDomains || [])] : [];
}
function e2eDomainsForPath(filePath) {
const mapping = findPathMapping(filePath);
return mapping ? [...(mapping.e2eDomains || [])] : [];
}
function matchesFullFallback(filePath) {
const normalized = normalizeRepoPath(filePath);
return (domainMap.fullFallbackPrefixes || []).some((prefix) => normalized.startsWith(prefix));
}
function isSkippablePath(filePath) {
const normalized = normalizeRepoPath(filePath);
const basename = path.posix.basename(normalized);
return (domainMap.skipPrefixes || []).some((prefix) => normalized.startsWith(prefix))
|| (domainMap.skipSuffixes || []).some((suffix) => normalized.endsWith(suffix))
|| (domainMap.skipFilenames || []).includes(basename);
}
module.exports = {
domainMap,
e2eDomainsForPath,
findPathMapping,
isSkippablePath,
labelDomainsForPath,
matchesFullFallback,
normalizeRepoPath,
};
+71
View File
@@ -0,0 +1,71 @@
{
"pathMappings": [
{ "prefix": "shortcuts/im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
{ "prefix": "shortcuts/vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
{ "prefix": "shortcuts/calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
{ "prefix": "shortcuts/doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
{ "prefix": "shortcuts/sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
{ "prefix": "shortcuts/drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
{ "prefix": "shortcuts/wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
{ "prefix": "shortcuts/base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
{ "prefix": "shortcuts/mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
{ "prefix": "shortcuts/task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
{ "prefix": "shortcuts/contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
{ "prefix": "shortcuts/apps/", "labelDomains": [], "e2eDomains": ["apps"] },
{ "prefix": "shortcuts/markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
{ "prefix": "shortcuts/minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
{ "prefix": "shortcuts/okr/", "labelDomains": [], "e2eDomains": ["okr"] },
{ "prefix": "shortcuts/slides/", "labelDomains": [], "e2eDomains": ["slides"] },
{ "prefix": "shortcuts/note/", "labelDomains": [], "e2eDomains": ["note"] },
{ "prefix": "shortcuts/event/", "labelDomains": [], "e2eDomains": ["event"] },
{ "prefix": "skills/lark-im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
{ "prefix": "skills/lark-vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
{ "prefix": "skills/lark-doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
{ "prefix": "skills/lark-wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
{ "prefix": "skills/lark-drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
{ "prefix": "skills/lark-sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
{ "prefix": "skills/lark-base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
{ "prefix": "skills/lark-mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
{ "prefix": "skills/lark-calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
{ "prefix": "skills/lark-task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
{ "prefix": "skills/lark-contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
{ "prefix": "skills/lark-apps/", "labelDomains": [], "e2eDomains": ["apps"] },
{ "prefix": "skills/lark-markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
{ "prefix": "skills/lark-minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
{ "prefix": "skills/lark-okr/", "labelDomains": [], "e2eDomains": ["okr"] },
{ "prefix": "skills/lark-slides/", "labelDomains": [], "e2eDomains": ["slides"] },
{ "prefix": "skills/lark-note/", "labelDomains": [], "e2eDomains": ["note"] },
{ "prefix": "skills/lark-event/", "labelDomains": [], "e2eDomains": ["event"] }
],
"fullFallbackPrefixes": [
"shortcuts/common/",
"cmd/",
"internal/",
"pkg/",
"extension/",
"registry/",
"go.mod",
"go.sum",
"Makefile",
".github/workflows/",
"scripts/"
],
"skipPrefixes": [
"docs/",
".changeset/"
],
"skipSuffixes": [
".md",
".mdx",
".txt",
".rst"
],
"skipFilenames": [
"readme.md",
"readme.zh.md",
"changelog.md",
"license",
"cla.md"
]
}
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const {
e2eDomainsForPath,
findPathMapping,
isSkippablePath,
matchesFullFallback,
normalizeRepoPath,
} = require("./domain-map");
const ROOT = process.env.E2E_DOMAINS_ROOT || path.join(__dirname, "..");
process.chdir(ROOT);
function execLines(command, args) {
return execFileSync(command, args, { encoding: "utf8" })
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
function modulePath() {
return execLines("go", ["list", "-m"])[0];
}
function rootPackage(moduleName) {
return `${moduleName}/tests/cli_e2e`;
}
function allLivePackages(moduleName) {
return execLines("go", ["list", "./tests/cli_e2e/..."])
.filter((pkg) => pkg !== rootPackage(moduleName))
.filter((pkg) => !pkg.endsWith("/demo"));
}
function allDryPackages(moduleName) {
return allLivePackages(moduleName);
}
const domainExistsCache = new Map();
function domainExists(domain) {
if (domainExistsCache.has(domain)) {
return domainExistsCache.get(domain);
}
let exists = false;
try {
execFileSync("go", ["list", `./tests/cli_e2e/${domain}`], { stdio: "ignore" });
exists = true;
} catch {
exists = false;
}
domainExistsCache.set(domain, exists);
return exists;
}
function readChangedFiles() {
const changedFilesPath = process.env.E2E_DOMAIN_CHANGED_FILES;
if (changedFilesPath) {
return fs.readFileSync(changedFilesPath, "utf8")
.split(/\r?\n/)
.map(normalizeRepoPath)
.filter(Boolean);
}
if (process.env.GITHUB_EVENT_NAME !== "pull_request") {
return null;
}
const baseRef = process.env.GITHUB_BASE_REF || "main";
try {
execFileSync("git", ["rev-parse", "--verify", `origin/${baseRef}`], { stdio: "ignore" });
return execLines("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`]).map(normalizeRepoPath);
} catch {
return null;
}
}
function addDomain(domains, domain) {
if (domain && domainExists(domain)) {
domains.add(domain);
return true;
}
return false;
}
function classifyPath(filePath, domains) {
const normalized = normalizeRepoPath(filePath);
if (!normalized) return { matched: false };
const e2eMatch = normalized.match(/^tests\/cli_e2e\/([^/]+)\//);
if (e2eMatch) {
const domain = e2eMatch[1];
if (domain === "demo") return { matched: false };
if (domainExists(domain)) {
addDomain(domains, domain);
return { matched: true };
}
if (isSkippablePath(normalized)) return { matched: false };
return { fullReason: `unknown CLI E2E domain path: ${normalized}` };
}
if (normalized.startsWith("tests/cli_e2e/")) {
return { fullReason: `shared CLI E2E harness changed: ${normalized}` };
}
if (matchesFullFallback(normalized)) {
return { fullReason: `shared/runtime path changed: ${normalized}` };
}
const mappedDomains = e2eDomainsForPath(normalized);
if (mappedDomains.length > 0) {
const missingDomains = [];
for (const domain of mappedDomains) {
if (!addDomain(domains, domain)) missingDomains.push(domain);
}
if (missingDomains.length > 0) {
return { fullReason: `mapped CLI E2E domain has no package: ${missingDomains.join(",")} (${normalized})` };
}
return { matched: true };
}
if (findPathMapping(normalized)) {
return { fullReason: `mapped path has no CLI E2E package: ${normalized}` };
}
if (normalized.match(/^shortcuts\/[^/]+\//) || normalized.match(/^skills\/lark-[^/]+\//)) {
return { fullReason: `unmapped CLI E2E domain path: ${normalized}` };
}
if (isSkippablePath(normalized)) return { matched: false };
return { fullReason: `unclassified path changed: ${normalized}` };
}
function resolveDomains(changedFiles) {
const moduleName = modulePath();
const rootDryPackage = rootPackage(moduleName);
if (changedFiles === null) {
return {
mode: "full",
reason: "non-pull_request run or unavailable diff",
domains: ["all"],
dryRootPackage: rootDryPackage,
dryPackages: allDryPackages(moduleName),
livePackages: allLivePackages(moduleName),
};
}
const domains = new Set();
let matchedRelevant = false;
let fullReason = "";
for (const file of changedFiles) {
const result = classifyPath(file, domains);
if (result.matched) matchedRelevant = true;
if (result.fullReason && !fullReason) fullReason = result.fullReason;
}
if (fullReason) {
return {
mode: "full",
reason: fullReason,
domains: ["all"],
dryRootPackage: rootDryPackage,
dryPackages: allDryPackages(moduleName),
livePackages: allLivePackages(moduleName),
};
}
if (matchedRelevant && domains.size > 0) {
const sortedDomains = [...domains].sort();
const packages = sortedDomains.map((domain) => `${moduleName}/tests/cli_e2e/${domain}`);
return {
mode: "subset",
reason: "business domain changes",
domains: sortedDomains,
dryRootPackage: rootDryPackage,
dryPackages: packages,
livePackages: packages,
};
}
return {
mode: "skip",
reason: "docs-only or no live CLI E2E impact",
domains: [],
dryRootPackage: "",
dryPackages: [],
livePackages: [],
};
}
function emit(resolved) {
const values = {
mode: resolved.mode,
reason: resolved.reason,
domains: resolved.domains.join(","),
dry_root_package: resolved.dryRootPackage,
dry_packages: resolved.dryPackages.join(" "),
live_packages: resolved.livePackages.join(" "),
};
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
console.log(lines.join("\n"));
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
}
}
if (require.main === module) {
emit(resolveDomains(readChangedFiles()));
}
module.exports = {
classifyPath,
readChangedFiles,
resolveDomains,
};
+94
View File
@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const test = require("node:test");
const scriptPath = path.join(__dirname, "e2e_domains.js");
function parseOutput(raw) {
const result = {};
for (const line of raw.trim().split(/\r?\n/)) {
const idx = line.indexOf("=");
if (idx === -1) continue;
result[line.slice(0, idx)] = line.slice(idx + 1);
}
return result;
}
function runDomains(files) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-domains-"));
const file = path.join(dir, "changed.txt");
fs.writeFileSync(file, `${files.join("\n")}\n`);
try {
return parseOutput(execFileSync(process.execPath, [scriptPath], {
cwd: path.join(__dirname, ".."),
encoding: "utf8",
env: { ...process.env, E2E_DOMAIN_CHANGED_FILES: file },
}));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
test("maps shortcut changes to one business domain package", () => {
const output = runDomains(["shortcuts/im/messages/send.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "im");
assert.match(output.dry_root_package, /github\.com\/larksuite\/cli\/tests\/cli_e2e$/);
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/im/);
assert.doesNotMatch(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
});
test("maps doc shortcuts to docs package", () => {
const output = runDomains(["shortcuts/doc/update.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "docs");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/docs/);
});
test("maps direct e2e domain package changes", () => {
const output = runDomains(["tests/cli_e2e/drive/helpers.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "drive");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
});
test("falls back to full for shared e2e harness changes", () => {
const output = runDomains(["tests/cli_e2e/core.go"]);
assert.equal(output.mode, "full");
assert.equal(output.domains, "all");
assert.match(output.reason, /shared CLI E2E harness changed/);
});
test("falls back to full for runtime changes", () => {
const output = runDomains(["cmd/root.go"]);
assert.equal(output.mode, "full");
assert.equal(output.domains, "all");
assert.match(output.reason, /shared\/runtime path changed/);
});
test("skips docs-only changes", () => {
const output = runDomains(["docs/usage.md", "README.md"]);
assert.equal(output.mode, "skip");
assert.equal(output.domains, "");
assert.equal(output.dry_root_package, "");
assert.equal(output.live_packages, "");
});
test("uses shared map for skill domain changes", () => {
const output = runDomains(["skills/lark-sheets/SKILL.md"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "sheets");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/sheets/);
});
test("falls back to full when a mapped path has no e2e package", () => {
const output = runDomains(["shortcuts/whiteboard/export.go"]);
assert.equal(output.mode, "full");
assert.match(output.reason, /unmapped CLI E2E domain path/);
});
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Fetch meta_data.json from remote API for build-time embedding.
Usage:
python3 scripts/fetch_meta.py # fetch from feishu (default)
python3 scripts/fetch_meta.py --brand lark # fetch from larksuite
"""
import argparse
import json
import os
import subprocess
import sys
import urllib.request
import urllib.error
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.join(SCRIPT_DIR, "..")
OUT_PATH = os.path.join(ROOT, "internal", "registry", "meta_data.json")
API_HOSTS = {
"feishu": "https://open.feishu.cn/api/tools/open/api_definition",
"lark": "https://open.larksuite.com/api/tools/open/api_definition",
}
TIMEOUT = 10 # seconds
def get_version():
"""Get version from git tags, matching Makefile logic."""
try:
return subprocess.check_output(
["git", "describe", "--tags", "--always", "--dirty"],
stderr=subprocess.DEVNULL,
text=True,
cwd=ROOT,
).strip()
except Exception:
return "dev"
def fetch_remote(brand):
"""Fetch MergedRegistry from remote API."""
base = API_HOSTS.get(brand, API_HOSTS["feishu"])
version = get_version()
url = f"{base}?protocol=meta&client_version={urllib.request.quote(version)}"
print(f"fetch-meta: GET {url}", file=sys.stderr)
req = urllib.request.Request(url)
resp = urllib.request.urlopen(req, timeout=TIMEOUT)
body = resp.read()
envelope = json.loads(body)
if envelope.get("msg") != "succeeded":
raise RuntimeError(f"unexpected response msg: {envelope.get('msg')!r}")
data = envelope.get("data", {})
if not data.get("services"):
raise RuntimeError("remote returned empty services")
return data
def main():
parser = argparse.ArgumentParser(description="Fetch meta_data.json for build-time embedding")
parser.add_argument("--brand", default="feishu", choices=["feishu", "lark"],
help="API brand (default: feishu)")
parser.add_argument("--force", action="store_true",
help="force refresh from remote even if local file exists")
args = parser.parse_args()
if os.path.exists(OUT_PATH) and not args.force:
if os.path.isfile(OUT_PATH):
try:
with open(OUT_PATH, "r", encoding="utf-8") as fp:
local = json.load(fp)
if local.get("services"):
print(f"fetch-meta: {OUT_PATH} already exists, skipping (use --force to re-fetch)", file=sys.stderr)
return
print(f"fetch-meta: {OUT_PATH} has no services, re-fetching", file=sys.stderr)
except (OSError, json.JSONDecodeError):
print(f"fetch-meta: {OUT_PATH} is invalid JSON, re-fetching", file=sys.stderr)
else:
print(f"fetch-meta: {OUT_PATH} is not a file, re-fetching", file=sys.stderr)
data = fetch_remote(args.brand)
count = len(data.get("services", []))
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
with open(OUT_PATH, "w", encoding="utf-8", newline="\n") as fp:
json.dump(data, fp, ensure_ascii=False, indent=2)
fp.write("\n")
if __name__ == "__main__":
main()
+390
View File
@@ -0,0 +1,390 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("fs");
const path = require("path");
const { execFileSync, execFile } = require("child_process");
// @clack/prompts is ESM-only since v1; load it via dynamic import() so this
// CommonJS script works on all supported Node versions (require() of an ESM
// package throws ERR_REQUIRE_ESM before Node 22.12). Assigned in the entry
// point below before main() runs.
let p;
const PKG = "@larksuite/cli";
const SKILLS_REPO = "https://open.feishu.cn";
const SKILLS_REPO_FALLBACK = "larksuite/cli";
const isWindows = process.platform === "win32";
// ---------------------------------------------------------------------------
// i18n
// ---------------------------------------------------------------------------
const messages = {
zh: {
setup: "正在设置 Feishu/Lark CLI...",
step1: "正在安装 %s...",
step1Upgrade: "正在升级 %s (v%s → v%s)...",
step1Skip: "已安装 (v%s),跳过",
step1Done: "已全局安装",
step1Upgraded: "已升级到 v%s",
step1Fail: "全局安装失败。运行以下命令重试: npm install -g %s",
step2: "安装 AI Skills",
step2Skip: "已安装,跳过",
step2Spinner: "正在安装 Skills...",
step2Done: "Skills 已安装",
step2Fail: "Skills 安装失败。运行以下命令重试: npx skills add %s -y -g",
step3: "正在配置应用...",
step3NotFound: "未找到 lark-cli,终止",
step3Found: "发现已配置应用 (App ID: %s),继续使用?",
step3Skip: "跳过应用配置",
step3Done: "应用已配置",
step3Fail: "应用配置失败。运行以下命令重试: lark-cli config init --new",
step4: "授权",
step4NotFound: "未找到 lark-cli,跳过授权",
step4Confirm: "是否允许 AI 访问你个人的消息、文档、日历等飞书 / Lark 数据,并以你的名义执行操作?",
step4Skip: "跳过授权。后续运行 lark-cli auth login 完成授权",
step4Done: "授权完成",
step4Fail: "授权失败。运行以下命令重试: lark-cli auth login",
done: "安装完成!\n可以和你的 AI 工具(如 Claude Code、Trae等)说:\"飞书/Lark CLI 能帮我做什么?结合我的情况推荐一下从哪里开始\"",
cancelled: "安装已取消",
nonTtyHint: "要完成配置,请在终端中运行:\n lark-cli config init --new\n lark-cli auth login",
},
en: {
setup: "Setting up Feishu/Lark CLI...",
step1: "Installing %s globally...",
step1Upgrade: "Upgrading %s (v%s → v%s)...",
step1Skip: "Already installed (v%s). Skipped",
step1Done: "Installed globally",
step1Upgraded: "Upgraded to v%s",
step1Fail: "Failed to install globally. Run manually: npm install -g %s",
step2: "Install AI skills",
step2Skip: "Already installed. Skipped",
step2Spinner: "Installing skills...",
step2Done: "Skills installed",
step2Fail: "Failed to install skills. Run manually: npx skills add %s -y -g",
step3: "Configuring app...",
step3NotFound: "lark-cli not found. Aborting",
step3Found: "Found existing app (App ID: %s). Use this app?",
step3Skip: "Skipped app configuration",
step3Done: "App configured",
step3Fail: "Failed to configure app. Run manually: lark-cli config init --new",
step4: "Authorization",
step4NotFound: "lark-cli not found. Skipping authorization",
step4Confirm: "Allow the AI to access your messages, documents, calendar, and more in Feishu/Lark, and perform actions on your behalf?",
step4Skip: "Skipped. Run lark-cli auth login to authorize later",
step4Done: "Authorization complete",
step4Fail: "Failed to authorize. Run lark-cli auth login to retry",
done: "You are all set!\nNow try asking your AI tool (Claude Code, Trae, etc.): \"What can Feishu/Lark CLI help me with, and where should I start?\"",
cancelled: "Installation cancelled",
nonTtyHint: "To complete setup, run interactively:\n lark-cli config init --new\n lark-cli auth login",
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function handleCancel(value, msg) {
if (p.isCancel(value)) {
p.cancel(msg.cancelled);
process.exit(0);
}
return value;
}
function execCmd(cmd, args, opts) {
if (isWindows) {
return execFileSync("cmd.exe", ["/c", cmd, ...args], opts);
}
return execFileSync(cmd, args, opts);
}
function run(cmd, args, opts = {}) {
execCmd(cmd, args, { stdio: "inherit", ...opts });
}
function runSilent(cmd, args, opts = {}) {
return execCmd(cmd, args, {
stdio: ["ignore", "pipe", "pipe"],
...opts,
});
}
function runSilentAsync(cmd, args, opts = {}) {
const actualCmd = isWindows ? "cmd.exe" : cmd;
const actualArgs = isWindows ? ["/c", cmd, ...args] : args;
return new Promise((resolve, reject) => {
execFile(actualCmd, actualArgs, {
stdio: ["ignore", "pipe", "pipe"],
...opts,
}, (err, stdout) => {
if (err) reject(err);
else resolve(stdout);
});
});
}
function fmt(template, ...values) {
let i = 0;
return template.replace(/%s/g, () => values[i++] ?? "");
}
/** Resolve the path of globally installed lark-cli (skip npx temp copies). */
function whichLarkCli() {
try {
const prefix = execFileSync("npm", ["prefix", "-g"], {
stdio: ["ignore", "pipe", "pipe"],
}).toString().trim();
const bin = isWindows
? path.join(prefix, "lark-cli.cmd")
: path.join(prefix, "bin", "lark-cli");
if (fs.existsSync(bin)) return bin;
} catch (_) {
// fall through
}
// Fallback to which/where if npm prefix lookup fails.
try {
const cmd = isWindows ? "where" : "which";
return execFileSync(cmd, ["lark-cli"], { stdio: ["ignore", "pipe", "pipe"] })
.toString()
.split("\n")[0]
.trim();
} catch (_) {
return null;
}
}
/** Get the latest version of @larksuite/cli from the registry. Returns version or null. */
function getLatestVersion() {
try {
const out = runSilent("npm", ["view", PKG, "version"], { timeout: 15000 });
const ver = out.toString().trim();
return /^\d+\.\d+\.\d+/.test(ver) ? ver : null;
} catch (_) {
return null;
}
}
/** Compare two semver strings. Returns true if a < b. */
function semverLessThan(a, b) {
const pa = a.replace(/-.*$/, "").split(".").map(Number);
const pb = b.replace(/-.*$/, "").split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] || 0) < (pb[i] || 0)) return true;
if ((pa[i] || 0) > (pb[i] || 0)) return false;
}
return false;
}
/** Check whether @larksuite/cli is truly installed in npm global prefix. Returns version or null. */
function getGloballyInstalledVersion() {
try {
const out = runSilent("npm", ["list", "-g", PKG], { timeout: 15000 });
const match = out.toString().match(/@(\d+\.\d+\.\d+[^\s]*)/);
return match ? match[1] : "unknown";
} catch (_) {
return null;
}
}
/** Check whether lark-cli config already exists. Returns app ID or null. */
function getExistingAppId(binPath) {
try {
const out = runSilent(binPath, ["config", "show"], { timeout: 10000 });
const json = JSON.parse(out.toString());
return json.appId || null;
} catch (_) {
return null;
}
}
/** Parse --lang from process.argv, returns "zh", "en", or null. */
function parseLangArg() {
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
if (args[i] === "--lang" && args[i + 1]) {
const val = args[i + 1].toLowerCase();
if (val === "zh" || val === "en") return val;
}
if (args[i].startsWith("--lang=")) {
const val = args[i].split("=")[1].toLowerCase();
if (val === "zh" || val === "en") return val;
}
}
return null;
}
// ---------------------------------------------------------------------------
// Steps
// ---------------------------------------------------------------------------
async function stepSelectLang() {
const fromArg = parseLangArg();
if (fromArg) return fromArg;
const lang = await p.select({
message: "请选择语言 / Select language",
options: [
{ value: "zh", label: "中文" },
{ value: "en", label: "English" },
],
});
return handleCancel(lang, messages.zh);
}
async function stepInstallGlobally(msg) {
const installedVer = getGloballyInstalledVersion();
const latestVer = getLatestVersion();
const needsUpgrade = installedVer && latestVer && semverLessThan(installedVer, latestVer);
if (installedVer && !needsUpgrade) {
p.log.info(fmt(msg.step1Skip, installedVer));
return false;
}
const s = p.spinner();
if (needsUpgrade) {
s.start(fmt(msg.step1Upgrade, PKG, installedVer, latestVer));
} else {
s.start(fmt(msg.step1, PKG));
}
try {
await runSilentAsync("npm", ["install", "-g", PKG], { timeout: 120000 });
s.stop(needsUpgrade ? fmt(msg.step1Upgraded, latestVer) : msg.step1Done);
return needsUpgrade;
} catch (_) {
s.stop(fmt(msg.step1Fail, PKG));
process.exit(1);
}
}
async function skillsAlreadyInstalled() {
try {
const out = await runSilentAsync("npx", ["-y", "skills", "ls", "-g"], {
timeout: 120000,
});
return /^lark-/m.test(out.toString());
} catch (_) {
return false;
}
}
async function stepInstallSkills(msg) {
const s = p.spinner();
s.start(msg.step2Spinner);
try {
if (await skillsAlreadyInstalled()) {
s.stop(msg.step2Skip);
return;
}
try {
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO, "-y", "-g"], {
timeout: 120000,
});
} catch (_) {
await runSilentAsync("npx", ["-y", "skills", "add", SKILLS_REPO_FALLBACK, "-y", "-g"], {
timeout: 120000,
});
}
s.stop(msg.step2Done);
} catch (_) {
s.stop(fmt(msg.step2Fail, SKILLS_REPO_FALLBACK));
process.exit(1);
}
}
async function stepConfigInit(msg, lang) {
const s = p.spinner();
s.start(msg.step3);
const larkCli = whichLarkCli();
if (!larkCli) {
s.stop(msg.step3NotFound);
process.exit(1);
}
const appId = getExistingAppId(larkCli);
s.stop(msg.step3);
if (appId) {
const reuse = await p.confirm({
message: fmt(msg.step3Found, appId),
});
if (handleCancel(reuse, msg) && reuse) {
p.log.info(msg.step3Skip);
return;
}
}
try {
run(larkCli, ["config", "init", "--new", "--lang", lang]);
p.log.success(msg.step3Done);
} catch (_) {
p.log.error(msg.step3Fail);
process.exit(1);
}
}
async function stepAuthLogin(msg) {
const larkCli = whichLarkCli();
if (!larkCli) {
p.log.warn(msg.step4NotFound);
return;
}
const yes = await p.confirm({
message: msg.step4Confirm,
});
if (p.isCancel(yes)) {
p.cancel(msg.cancelled);
process.exit(0);
}
if (!yes) {
p.log.info(msg.step4Skip);
return;
}
p.log.step(msg.step4);
try {
run(larkCli, ["auth", "login"]);
p.log.success(msg.step4Done);
} catch (_) {
p.log.warn(msg.step4Fail);
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const isInteractive = !!process.stdin.isTTY;
const lang = isInteractive ? await stepSelectLang() : (parseLangArg() || "en");
const msg = messages[lang];
if (isInteractive) {
p.intro(msg.setup);
await stepInstallGlobally(msg);
await stepInstallSkills(msg);
await stepConfigInit(msg, lang);
await stepAuthLogin(msg);
p.outro(msg.done);
} else {
console.log(msg.setup);
await stepInstallGlobally(msg);
await stepInstallSkills(msg);
console.log(msg.nonTtyHint);
}
}
(async () => {
p = await import("@clack/prompts");
await main();
})().catch((err) => {
const msg = "Unexpected error: " + (err.message || err);
if (p) p.cancel(msg);
else console.error(msg);
process.exit(1);
});
+347
View File
@@ -0,0 +1,347 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("fs");
const path = require("path");
const { execFileSync } = require("child_process");
const os = require("os");
const crypto = require("crypto");
const VERSION = require("../package.json").version.replace(/-.*$/, "");
const REPO = "larksuite/cli";
const NAME = "lark-cli";
const DEFAULT_MIRROR_HOST = "https://registry.npmmirror.com";
// Allowlist gates the *initial* request URL only. curl --location follows
// redirects (capped by --max-redirs 3) without re-checking the target host.
// This is acceptable because checksum verification is the primary integrity
// control; the allowlist is defense-in-depth to reject obviously wrong URLs.
const ALLOWED_HOSTS = new Set([
"github.com",
"objects.githubusercontent.com",
"registry.npmmirror.com",
]);
const PLATFORM_MAP = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const ARCH_MAP = {
x64: "amd64",
arm64: "arm64",
riscv64: "riscv64",
};
const platform = PLATFORM_MAP[process.platform];
const arch = ARCH_MAP[process.arch];
const isWindows = process.platform === "win32";
const ext = isWindows ? ".zip" : ".tar.gz";
const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`;
const GITHUB_URL = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`;
const binDir = path.join(__dirname, "..", "bin");
const dest = path.join(binDir, NAME + (isWindows ? ".exe" : ""));
// Build the ordered list of binary mirror URLs to try. Resolution rules:
// 1. npm_config_registry — when the user has set a non-default
// registry (npmmirror clone, corp Verdaccio,
// Artifactory, …), include the derived path
// first. Many of these proxies don't actually
// host /-/binary/<pkg>/..., so we ALWAYS
// append the public npmmirror as a final
// fallback so the install does not regress
// from the previous behavior of "GitHub →
// npmmirror".
// 2. registry.npmmirror.com — public China mirror, always tried last.
// The default public npmjs registry is skipped in step 1 because it does not
// host binaries under /-/binary/...
//
// Non-https / malformed npm_config_registry is silently ignored so npm users
// with http-only internal registries don't have their installs broken.
function resolveMirrorUrls(env, archive, version) {
const binaryPath = `/-/binary/lark-cli/v${version}/${archive}`;
const defaultUrl = joinUrl(DEFAULT_MIRROR_HOST, binaryPath);
const urls = [];
const registry = (env.npm_config_registry || "").trim();
if (registry && !isDefaultNpmjsRegistry(registry) && isValidDownloadBase(registry)) {
const base = new URL(registry);
urls.push(joinUrl(base.origin + base.pathname, binaryPath));
}
if (!urls.includes(defaultUrl)) urls.push(defaultUrl);
return urls;
}
function joinUrl(base, suffix) {
return base.replace(/\/+$/, "") + suffix;
}
function isValidDownloadBase(raw) {
try {
const parsed = new URL(raw);
return parsed.protocol === "https:" && !!parsed.hostname;
} catch (_) {
return false;
}
}
function isDefaultNpmjsRegistry(url) {
try {
const { hostname } = new URL(url);
return hostname === "registry.npmjs.org";
} catch (_) {
return false;
}
}
function assertAllowedHost(url) {
const { hostname } = new URL(url);
if (!ALLOWED_HOSTS.has(hostname)) {
throw new Error(`Download host not allowed: ${hostname}`);
}
}
// Resolve the mirror URL chain and admit each host. Called from install() so
// derived hosts only become trusted when actually needed.
function getMirrorUrls(env) {
const urls = resolveMirrorUrls(env, archiveName, VERSION);
for (const u of urls) ALLOWED_HOSTS.add(new URL(u).hostname);
return urls;
}
/**
* Decide from a `curl --version` output whether curl is >= 7.70.0 — the
* release (2020-04-29) that introduced --ssl-revoke-best-effort. Kept pure
* (no I/O) so the version-comparison logic can be unit tested without
* spawning a process. Reads the leading "curl X.Y.Z" token, ignoring the
* trailing "libcurl/X.Y.Z" that may report a different version.
*
* @param {string} versionOutput raw stdout of `curl --version`
* @returns {boolean} true when the parsed version is >= 7.70.0
*/
function isCurlVersionSupported(versionOutput) {
const match = String(versionOutput).match(/^\s*curl\s+(\d+)\.(\d+)\.(\d+)/i);
if (!match) return false;
const major = parseInt(match[1], 10);
const minor = parseInt(match[2], 10);
return major > 7 || (major === 7 && minor >= 70);
}
// Memoized probe result. curl's version is invariant for the lifetime of the
// install, while download() runs once per mirror URL — so probe at most once.
let _curlSupportsSslRevokeBestEffort;
/**
* Detect whether the system curl supports --ssl-revoke-best-effort. Older
* versions (notably the curl 7.55.1 shipped with older Windows 10 builds)
* exit with "unknown option" if the flag is passed.
*
* @returns {boolean} true when curl >= 7.70.0 is available
*/
function curlSupportsSslRevokeBestEffort() {
if (_curlSupportsSslRevokeBestEffort !== undefined) {
return _curlSupportsSslRevokeBestEffort;
}
try {
const output = execFileSync("curl", ["--version"], {
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
timeout: 5000,
});
_curlSupportsSslRevokeBestEffort = isCurlVersionSupported(output);
} catch (_) {
_curlSupportsSslRevokeBestEffort = false;
}
return _curlSupportsSslRevokeBestEffort;
}
function download(url, destPath) {
assertAllowedHost(url);
const args = [
"--fail", "--location", "--silent", "--show-error",
"--connect-timeout", "10", "--max-time", "120",
"--max-redirs", "3",
"--output", destPath,
];
// --ssl-revoke-best-effort: on Windows (Schannel), avoid CRYPT_E_REVOCATION_OFFLINE
// errors when the certificate revocation list server is unreachable.
// Only use it when the system curl is new enough (>= 7.70.0).
if (isWindows && curlSupportsSslRevokeBestEffort()) {
args.unshift("--ssl-revoke-best-effort");
}
args.push(url);
execFileSync("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
}
function extractZipWindows(archivePath, destDir) {
const psOpts = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command"];
const psStdio = ["ignore", "inherit", "inherit"];
const psEnv = {
...process.env,
LARK_CLI_ARCHIVE: archivePath,
LARK_CLI_DEST: destDir,
};
try {
const dotnet =
"$ErrorActionPreference='Stop';" +
"Add-Type -AssemblyName System.IO.Compression.FileSystem;" +
"[System.IO.Compression.ZipFile]::ExtractToDirectory($env:LARK_CLI_ARCHIVE,$env:LARK_CLI_DEST)";
execFileSync("powershell.exe", [...psOpts, dotnet], { stdio: psStdio, env: psEnv });
} catch (primaryErr) {
try {
const cmdlet =
"$ErrorActionPreference='Stop';" +
"Expand-Archive -LiteralPath $env:LARK_CLI_ARCHIVE -DestinationPath $env:LARK_CLI_DEST -Force";
execFileSync("powershell.exe", [...psOpts, cmdlet], { stdio: psStdio, env: psEnv });
} catch (secondErr) {
try {
execFileSync("tar", ["-xf", archivePath, "-C", destDir], { stdio: psStdio });
} catch (fallbackErr) {
throw new Error(
`Failed to extract ${archivePath}. ` +
`.NET ZipFile attempt: ${primaryErr.message}. ` +
`Expand-Archive fallback: ${secondErr.message}. ` +
`tar fallback: ${fallbackErr.message}`
);
}
}
}
}
function install() {
const mirrorUrls = getMirrorUrls(process.env);
const downloadUrls = [GITHUB_URL, ...mirrorUrls];
fs.mkdirSync(binDir, { recursive: true });
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-"));
const archivePath = path.join(tmpDir, archiveName);
try {
// Walk the chain in order; stop at the first success. Default chain:
// GitHub → derived(npm_config_registry)? → npmmirror. The npmmirror
// tail preserves the pre-PR safety net when a corporate proxy doesn't
// actually host /-/binary/<pkg>/...
let lastErr;
let downloaded = false;
for (const url of downloadUrls) {
try {
download(url, archivePath);
downloaded = true;
break;
} catch (e) {
lastErr = e;
}
}
if (!downloaded) throw lastErr;
const expectedHash = getExpectedChecksum(archiveName);
verifyChecksum(archivePath, expectedHash);
if (isWindows) {
extractZipWindows(archivePath, tmpDir);
} else {
execFileSync("tar", ["-xzf", archivePath, "-C", tmpDir], {
stdio: "ignore",
});
}
const binaryName = NAME + (isWindows ? ".exe" : "");
const extractedBinary = path.join(tmpDir, binaryName);
fs.copyFileSync(extractedBinary, dest);
fs.chmodSync(dest, 0o755);
console.log(`${NAME} v${VERSION} installed successfully`);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
function getExpectedChecksum(archiveName, checksumsDir) {
const dir = checksumsDir || path.join(__dirname, "..");
const checksumsPath = path.join(dir, "checksums.txt");
if (!fs.existsSync(checksumsPath)) {
console.error(
"[WARN] checksums.txt not found, skipping checksum verification"
);
return null;
}
const content = fs.readFileSync(checksumsPath, "utf8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const idx = trimmed.indexOf(" ");
if (idx === -1) continue;
const hash = trimmed.slice(0, idx);
const name = trimmed.slice(idx + 2);
if (name === archiveName) return hash;
}
throw new Error(`Checksum entry not found for ${archiveName}`);
}
function verifyChecksum(archivePath, expectedHash) {
if (expectedHash === null) return;
// Stream the file to avoid loading the entire archive into memory.
// Archives can be 10-100MB; streaming keeps RSS constant.
const hash = crypto.createHash("sha256");
const fd = fs.openSync(archivePath, "r");
try {
const buf = Buffer.alloc(64 * 1024);
let bytesRead;
while ((bytesRead = fs.readSync(fd, buf, 0, buf.length, null)) > 0) {
hash.update(buf.subarray(0, bytesRead));
}
} finally {
fs.closeSync(fd);
}
const actual = hash.digest("hex");
if (actual.toLowerCase() !== expectedHash.toLowerCase()) {
throw new Error(
`[SECURITY] Checksum mismatch for ${path.basename(archivePath)}: expected ${expectedHash} but got ${actual}`
);
}
}
if (require.main === module) {
if (!platform || !arch) {
console.error(
`Unsupported platform: ${process.platform}-${process.arch}`
);
process.exit(1);
}
// When triggered as a postinstall hook under npx, skip the binary download.
// The "install" wizard doesn't need it, and run.js calls install.js directly
// (with LARK_CLI_RUN=1) for other commands that do need the binary.
const isNpxPostinstall =
process.env.npm_command === "exec" && !process.env.LARK_CLI_RUN;
if (isNpxPostinstall) {
process.exit(0);
}
try {
install();
} catch (err) {
console.error(`Failed to install ${NAME}:`, err.message);
console.error(
`\nIf you are behind a firewall or in a restricted network, try one of:\n` +
` # 1. Use a proxy:\n` +
` export https_proxy=http://your-proxy:port\n` +
` npm install -g @larksuite/cli\n\n` +
` # 2. Point to a corporate npm mirror that proxies /-/binary/lark-cli/...:\n` +
` npm install -g @larksuite/cli --registry=https://your-corp-mirror/`
);
process.exit(1);
}
}
module.exports = { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls, curlSupportsSslRevokeBestEffort, isCurlVersionSupported };
+332
View File
@@ -0,0 +1,332 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const path = require("path");
const os = require("os");
const crypto = require("crypto");
const { getExpectedChecksum, verifyChecksum, assertAllowedHost, resolveMirrorUrls, isCurlVersionSupported } = require("./install.js");
describe("getExpectedChecksum", () => {
function makeTmpChecksums(content) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
fs.writeFileSync(path.join(dir, "checksums.txt"), content, "utf8");
return dir;
}
it("returns correct hash from standard-format checksums.txt", () => {
const dir = makeTmpChecksums(
"abc123def456 lark-cli-1.0.0-darwin-arm64.tar.gz\n"
);
const hash = getExpectedChecksum(
"lark-cli-1.0.0-darwin-arm64.tar.gz",
dir
);
assert.equal(hash, "abc123def456");
});
it("returns correct entry when multiple entries exist", () => {
const dir = makeTmpChecksums(
"aaaa lark-cli-1.0.0-linux-amd64.tar.gz\n" +
"bbbb lark-cli-1.0.0-darwin-arm64.tar.gz\n" +
"cccc lark-cli-1.0.0-windows-amd64.zip\n"
);
const hash = getExpectedChecksum(
"lark-cli-1.0.0-darwin-arm64.tar.gz",
dir
);
assert.equal(hash, "bbbb");
});
it("throws Error when archiveName is not found", () => {
const dir = makeTmpChecksums(
"aaaa lark-cli-1.0.0-linux-amd64.tar.gz\n"
);
assert.throws(
() => getExpectedChecksum("nonexistent.tar.gz", dir),
{ message: /Checksum entry not found for nonexistent\.tar\.gz/ }
);
});
it("returns null when checksums.txt does not exist", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
// No checksums.txt in dir
const result = getExpectedChecksum("anything.tar.gz", dir);
assert.equal(result, null);
});
it("skips malformed lines and still finds valid entry", () => {
const dir = makeTmpChecksums(
"garbage line without separator\n" +
"\n" +
"abc123 lark-cli-1.0.0-darwin-arm64.tar.gz\n" +
"also garbage\n"
);
const hash = getExpectedChecksum(
"lark-cli-1.0.0-darwin-arm64.tar.gz",
dir
);
assert.equal(hash, "abc123");
});
it("skips tab-separated lines (only double-space is valid)", () => {
const dir = makeTmpChecksums(
"wrong\tlark-cli-1.0.0-darwin-arm64.tar.gz\n" +
"correct lark-cli-1.0.0-darwin-arm64.tar.gz\n"
);
const hash = getExpectedChecksum(
"lark-cli-1.0.0-darwin-arm64.tar.gz",
dir
);
assert.equal(hash, "correct");
});
});
describe("verifyChecksum", () => {
function makeTmpFile(content) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "checksum-test-"));
const filePath = path.join(dir, "archive.tar.gz");
fs.writeFileSync(filePath, content);
return filePath;
}
function sha256(content) {
return crypto.createHash("sha256").update(content).digest("hex");
}
it("returns normally when hash matches", () => {
const content = "binary content here";
const filePath = makeTmpFile(content);
const hash = sha256(content);
// Should not throw
verifyChecksum(filePath, hash);
});
it("matches case-insensitively", () => {
const content = "case test";
const filePath = makeTmpFile(content);
const hash = sha256(content).toUpperCase();
// Should not throw
verifyChecksum(filePath, hash);
});
it("throws [SECURITY]-prefixed Error on mismatch", () => {
const filePath = makeTmpFile("real content");
assert.throws(
() => verifyChecksum(filePath, "0000000000000000000000000000000000000000000000000000000000000000"),
(err) => {
assert.match(err.message, /^\[SECURITY\]/);
assert.match(err.message, /Checksum mismatch/);
return true;
}
);
});
});
describe("assertAllowedHost", () => {
it("accepts github.com", () => {
assertAllowedHost("https://github.com/larksuite/cli/releases/download/v1.0.0/archive.tar.gz");
});
it("accepts objects.githubusercontent.com", () => {
assertAllowedHost("https://objects.githubusercontent.com/some/path");
});
it("accepts registry.npmmirror.com", () => {
assertAllowedHost("https://registry.npmmirror.com/-/binary/lark-cli/v1.0.0/archive.tar.gz");
});
it("rejects unknown host", () => {
assert.throws(
() => assertAllowedHost("https://evil.example.com/payload"),
{ message: /Download host not allowed: evil\.example\.com/ }
);
});
it("normalizes hostname to lowercase", () => {
// URL constructor lowercases hostnames per spec
assertAllowedHost("https://GitHub.COM/larksuite/cli/releases/download/v1.0.0/a.tar.gz");
});
it("ignores port when matching hostname", () => {
// URL.hostname does not include port
assertAllowedHost("https://github.com:443/larksuite/cli/releases/download/v1.0.0/a.tar.gz");
});
it("throws on invalid URL", () => {
assert.throws(
() => assertAllowedHost("not-a-url"),
TypeError
);
});
});
describe("resolveMirrorUrls", () => {
const ARCHIVE = "lark-cli-1.0.0-linux-amd64.tar.gz";
const VERSION = "1.0.0";
const DEFAULT = "https://registry.npmmirror.com/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz";
it("returns only the default mirror when no env vars are set", () => {
assert.deepEqual(resolveMirrorUrls({}, ARCHIVE, VERSION), [DEFAULT]);
});
it("does not derive from the default npmjs registry", () => {
// The public npmjs registry doesn't host /-/binary/<pkg>/..., so we must
// not point downloads at it.
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "https://registry.npmjs.org/" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
it("derives from non-default npm_config_registry AND keeps default as fallback", () => {
// Critical: a corporate npm proxy (Verdaccio/Artifactory/Nexus) often
// doesn't actually serve /-/binary/<pkg>/..., so we must keep the
// public npmmirror as a final fallback or installs regress vs. the
// pre-PR "GitHub → npmmirror" behavior.
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "https://corp.example.com/repository/npm-public/" },
ARCHIVE,
VERSION
),
[
"https://corp.example.com/repository/npm-public/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz",
DEFAULT,
]
);
});
it("derived URL appears before the default in the chain", () => {
const urls = resolveMirrorUrls(
{ npm_config_registry: "https://corp.example.com/" },
ARCHIVE,
VERSION
);
assert.equal(urls.length, 2);
assert.match(urls[0], /^https:\/\/corp\.example\.com\//);
assert.equal(urls[1], DEFAULT);
});
it("does not duplicate the default if the registry already points at it", () => {
// If npm_config_registry happens to be the public npmmirror, we still
// want a single entry, not two identical ones.
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "https://registry.npmmirror.com/" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
it("strips trailing slashes from the registry URL", () => {
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "https://corp.example.com///" },
ARCHIVE,
VERSION
),
[
"https://corp.example.com/-/binary/lark-cli/v1.0.0/lark-cli-1.0.0-linux-amd64.tar.gz",
DEFAULT,
]
);
});
it("ignores empty/whitespace npm_config_registry", () => {
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
it("silently falls back when npm_config_registry is non-https", () => {
// Implicit feature: don't break installs whose npm registry is plain http.
// The user didn't opt into binary-mirror behavior, so just use the default.
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "http://internal.example.com/" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
it("silently falls back when npm_config_registry is file://", () => {
assert.deepEqual(
resolveMirrorUrls(
{ npm_config_registry: "file:///tmp" },
ARCHIVE,
VERSION
),
[DEFAULT]
);
});
});
describe("isCurlVersionSupported", () => {
// --ssl-revoke-best-effort was introduced in curl 7.70.0; below that the
// flag is unknown and `curl` exits non-zero (see issue #1099).
it("returns false for curl 7.55.1 (older Windows 10, flag unknown)", () => {
assert.equal(
isCurlVersionSupported("curl 7.55.1 (x86_64-pc-win32) libcurl/7.55.1"),
false
);
});
it("returns false for curl 7.69.0 (just below the 7.70.0 threshold)", () => {
assert.equal(
isCurlVersionSupported("curl 7.69.0 (x86_64-pc-win32) libcurl/7.69.0"),
false
);
});
it("returns true for curl 7.70.0 (flag introduced here)", () => {
assert.equal(
isCurlVersionSupported("curl 7.70.0 (x86_64-pc-win32) libcurl/7.70.0"),
true
);
});
it("returns true for a future major (curl 8.x)", () => {
assert.equal(
isCurlVersionSupported("curl 8.5.0 (x86_64-apple-darwin) libcurl/8.5.0"),
true
);
});
it("returns false when no version can be parsed", () => {
assert.equal(isCurlVersionSupported("not a curl version string"), false);
assert.equal(isCurlVersionSupported(""), false);
});
it("reads the leading 'curl X.Y.Z', not the trailing libcurl/X.Y.Z", () => {
// Guards the regex against latching onto "libcurl/7.55.1" when the
// curl binary itself is new enough.
assert.equal(
isCurlVersionSupported("curl 8.0.0 (x86_64) libcurl/7.55.1"),
true
);
});
it("does not match a 'libcurl X.Y.Z' token (anchored to leading curl)", () => {
// "libcurl 8.0.0" contains the substring "curl 8.0.0"; the leading
// anchor keeps it from being mistaken for a real curl version line.
assert.equal(isCurlVersionSupported("libcurl 8.0.0"), false);
});
});
+74
View File
@@ -0,0 +1,74 @@
# Issue Labels
This script searches unlabeled GitHub issues in a repository and applies labels based on heuristics. It is intentionally a one-shot triage pass: once any label is added to an issue, that issue is out of scope for future scheduled runs.
It only covers two label dimensions:
- **Type**: `bug` / `enhancement` / `question` / `documentation` / `performance` / `security`
- **Domain**: `domain/<service>` (multi-select)
Related GitHub Actions workflow: `.github/workflows/issue-labels.yml`.
## Labeling Rules (Current)
### Type (single-select; write only when matched)
- Candidates: `bug`, `enhancement`, `question`, `documentation`, `performance`, `security`
- Type is written **only when keywords are matched** in title/body. If nothing matches, the script will not add or correct type labels.
- By default, the script **does not override existing type labels** to avoid reverting manual triage. Use `--override-type` if you really want the script to enforce the computed type.
### Domain (multi-select; add-only by default)
- Managed labels prerequisite: the standard type labels plus `domain/<service>` labels should exist in the repository. If a specific issue needs a managed label that is missing, the script prints a warning, skips that issue, and continues processing the rest.
- Label format: `domain/<service>` (e.g. `domain/base`, `domain/im`)
- Signals (strong → weak):
1) Explicit `domain/<service>` in text
2) Command mention: `lark-cli <service>` / `lark cli <service>` (maps `docs``doc`)
3) Loose title match (careful; excludes English `im` to reduce false positives)
4) A small set of conservative keyword heuristics as fallback
- By default, the script only adds missing domain labels and never removes existing ones.
- If you want stricter domain synchronization, use `--sync-domains`.
- Note: the current implementation only removes existing `domain/*` labels when it can positively match at least one domain for the issue, so this is not an exact-sync cleanup mode.
## Usage
### GitHub Actions (recommended)
The workflow supports both:
- `schedule` (hourly)
- `workflow_dispatch` (manual run)
Scheduled runs write labels by default. Manual runs default to dry-run unless `dry_run=false` is selected.
Only issues with no labels are scanned. This is intentional: the automation is meant to triage brand-new unlabeled issues once, not to continuously reconcile labels on previously triaged issues.
### Local dry-run
Provide a token to avoid anonymous rate limits:
```bash
GITHUB_TOKEN=$(gh auth token) \
node scripts/issue-labels/index.js \
--repo larksuite/cli \
--max-issues 100 \
--dry-run --json
```
### Common Flags
- `--dry-run`: Do not write labels, only print planned changes
- `--json`: JSON output (usually with `--dry-run`)
- `--max-issues <n>` / `--max-pages <n>`: Bound unlabeled-issue search size for each run
- `--sync-domains`: Stricter `domain/*` sync when at least one managed domain matches (may still leave stale labels if nothing matches)
- `--override-type`: Allow overriding existing type labels (use with caution)
## Regression Samples
`samples.json` is a regression dataset sampled from real issues in `larksuite/cli` (issue bodies are truncated).
Run tests:
```bash
node scripts/issue-labels/test.js
```
+894
View File
@@ -0,0 +1,894 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
/*
* Issue labeler for this repository.
*
* Implements only:
* - Type labels (Section 2)
* - Domain labels (Section 4)
*
* Notes:
* - Type: only applied when keyword matched. If no match, keep current type labels unchanged.
* - Domain: default is add-only; strict sync is optional via --sync-domains.
*/
const API_BASE = "https://api.github.com";
const TYPE_LABELS = [
"bug",
"enhancement",
"question",
"documentation",
"performance",
"security",
];
const TYPE_LABEL_SET = new Set(TYPE_LABELS);
const DOMAIN_SERVICES = [
"im",
"doc",
"drive",
"base",
"sheets",
"calendar",
"mail",
"task",
"vc",
"whiteboard",
"minutes",
"wiki",
"event",
"auth",
"core",
];
const DOMAIN_ALIASES = ["docs"];
const DOMAIN_REGEX_ALTERNATION = [...DOMAIN_SERVICES, ...DOMAIN_ALIASES].join("|");
const DOMAIN_LABELS = DOMAIN_SERVICES.map((s) => `domain/${s}`);
const DOMAIN_LABEL_SET = new Set(DOMAIN_LABELS);
const MANAGED_LABELS = [...TYPE_LABELS, ...DOMAIN_LABELS];
const TYPE_TIE_BREAKER = [
"security",
"bug",
"performance",
"enhancement",
"documentation",
"question",
];
// More conservative type labeling: prefer "no label" over mislabeling.
// - Require a minimum score.
// - When the top two candidates are too close, treat as ambiguous and do not label.
const TYPE_MIN_SCORE = 2;
const TYPE_MIN_MARGIN = 1;
/**
* Pause execution for the provided number of milliseconds.
*
* @param {number} ms
* @returns {Promise<void>}
*/
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Read an environment variable and trim surrounding whitespace.
*
* @param {string} name
* @returns {string}
*/
function envValue(name) {
const value = process.env[name];
return value ? String(value).trim() : "";
}
/**
* Read a required environment variable.
*
* @param {string} name
* @returns {string}
*/
function envOrFail(name) {
const value = envValue(name);
if (!value) throw new Error(`missing required environment variable: ${name}`);
return value;
}
/**
* Parse an integer value with a fallback when parsing fails.
*
* @param {string|number|undefined|null} value
* @param {number} fallback
* @returns {number}
*/
function toInt(value, fallback) {
const n = Number.parseInt(String(value || ""), 10);
return Number.isFinite(n) ? n : fallback;
}
/**
* Parse a boolean-ish value from CLI or environment input.
*
* @param {string|boolean|undefined|null} value
* @returns {boolean}
*/
function toBool(value) {
if (typeof value === "boolean") return value;
const v = String(value || "").trim().toLowerCase();
if (!v) return false;
if (v === "1" || v === "true" || v === "yes" || v === "y") return true;
return false;
}
/**
* Normalize issue title and body into a single lowercase string.
*
* @param {string} title
* @param {string} body
* @returns {string}
*/
function normalizeText(title, body) {
return `${String(title || "")}\n\n${String(body || "")}`.toLowerCase();
}
/**
* Infer candidate domain services from issue title and body text.
*
* @param {string} title
* @param {string} body
* @returns {string[]}
*/
function collectDomainsFromText(title, body) {
const normalizedBody = String(body || "")
.replace(/(["'])(?:(?=(\\?))\2.)*?\1/gs, (segment) => {
return /lark-cli\s+/i.test(segment) && segment.length > 80 ? '""' : segment;
});
const text = normalizeText(title, normalizedBody);
const titleText = String(title || "").toLowerCase();
const hits = new Set();
function normalizeService(svc) {
const s = String(svc || "").toLowerCase();
if (s === "docs") return "doc";
return s;
}
// 1) Explicit domain labels in text: domain/<service>
const explicit = new RegExp(`\\bdomain\\/(${DOMAIN_REGEX_ALTERNATION})\\b`, "gi");
for (const match of text.matchAll(explicit)) {
const svc = match && match[1] ? normalizeService(match[1]) : "";
if (DOMAIN_SERVICES.includes(svc)) hits.add(svc);
}
// 2) Command mention: lark-cli <service> / lark cli <service>
const cmd = new RegExp(`\\blark[-\\s]?cli\\s+(${DOMAIN_REGEX_ALTERNATION})\\b`, "gi");
for (const match of text.matchAll(cmd)) {
const svc = match && match[1] ? normalizeService(match[1]) : "";
if (DOMAIN_SERVICES.includes(svc)) hits.add(svc);
}
// 3) Loose title match: if title contains a standalone service word.
// This is intentionally limited to TITLE to reduce false positives.
// NOTE: exclude `im` here because it's too common in English text (e.g. "im stuck").
const looseServices = DOMAIN_SERVICES.filter((s) => s !== "im");
for (const svc of looseServices) {
const pattern = svc === "doc" ? "\\bdocs?\\b" : `\\b${svc}\\b`;
const re = new RegExp(pattern, "i");
if (re.test(titleText)) hits.add(svc);
}
// 4) Keyword heuristics (for users who don't paste the exact command)
// Keep this conservative; add keywords only when they are strongly tied to a domain.
const keywordMap = {
base: [/\bbase\s*\+/i, /\bbase-token\b/i, /open-apis\/bitable\//i, /\brecords?\/(search|list)\b/i, /多维表格/],
doc: [/\bdocx\b/i, /\bfeishu document\b/i, /\blark document\b/i, /\bdocument comments?\b/i, /飞书文档|云文档|文档/],
drive: [/\bdrive\b/i, /\bfolder token\b/i, /create_folder/i, /drive\/v1\/files/i, /\bdrive\s*\+/i],
sheets: [/电子表格/, /\bsheets\s*\+/i],
calendar: [/日历/, /\bcalendar\s*\+/i],
mail: [/邮件/, /\bmail\s*\+/i],
task: [/任务清单/, /飞书任务/, /\btask\s*\+/i],
wiki: [/知识库/, /\bwiki\s*\+/i],
minutes: [/妙记/, /\bminutes\s*\+/i],
vc: [/\bvc\s*\+/i, /飞书会议|视频会议|创建会议/],
im: [/消息|群聊|私聊/, /\bim\s*\+/i, /im\/v1/i],
auth: [/\bauth\s+(login|status|check|logout)\b/i, /\bkeychain\b/i, /\buser_access_token\b/i, /\buser token\b/i, /\bconsent\b/i, /授权|登录|scope authorization/],
core: [/\bpostinstall\b/i, /\bconfig(\.json)?\b/i, /\bconfig\s+(init|show|remove)\b/i, /\bpackage\.json\b/i, /\bscripts\/install\.js\b/i, /\bbun\b/i, /\bskills?\b/i, /\btrae\b/i, /\bprofile\b/i, /\bmulti-account\b/i, /\bprivate deployment\b/i, /\bbinary release\b/i, /\bbinary fails?\b/i, /\bunsupported platform\b/i, /\bebadplatform\b/i, /\bwindows\b.*\bbinary\b|\bbinary\b.*\bwindows\b/i, /\briscv64\b.*\bsupport/i, /私有化|安装脚本|配置文件|多账号|多个应用|多用户|持久化连接|服务器端/],
};
for (const [svc, patterns] of Object.entries(keywordMap)) {
if (!DOMAIN_SERVICES.includes(svc)) continue;
for (const re of patterns) {
if (re.test(text)) {
hits.add(svc);
break;
}
}
}
return [...hits].sort();
}
/**
* Score each type label against the issue content.
*
* @param {string} title
* @param {string} body
* @returns {Record<string, number>}
*/
function scoreTypeFromText(title, body) {
const text = normalizeText(title, body);
const titleText = String(title || "").toLowerCase();
const rules = {
bug: [
// explicit
{ re: /\bbug\b/i, w: 2 },
// strong signals (stack traces, errors, crashes)
{ re: /\berror\b|\bexception\b|\bcrash\b|\bpanic\b|\bstack\s*trace\b|\bbroken\b|\bfails?\b|\bsigkill\b|\binvalid json\b|\bno stdout\b|\bno stderr\b|\bno output\b|\bsilently fail\w*\b|\bsilently drop\w*\b|\bdiscard\w*\b/i, w: 2 },
// Chinese strong/medium signals
{ re: /报错|错误|异常|崩溃|闪退|卡死|死机|无输出|静默失败|截断|不生效|无法正确|不能正确|被忽略|丢失|没有给/, w: 2 },
// Contextual "cannot/fail" patterns that are usually bugs (avoid labeling based on a bare "无法/失败").
{ re: /(无法|失败)(正常)?(.{0,16})?(使用|运行|执行|发送|创建|获取|写入|读取|安装|登录|导出|更新|上传|下载)/, w: 2 },
// weak motivation words (do NOT label bug based on these alone)
{ re: /无法|失败|没法|不能用|不可用/, w: 1 },
],
enhancement: [
// Chinese/English explicit feature request
{ re: /功能请求|需求|\bfeature request\b|\badd support\b|\bplease add\b/i, w: 2 },
{ re: /希望支持|建议|新增|支持.*(能力|功能)/, w: 2 },
// common Chinese ask forms that usually indicate a request
{ re: /能不能支持|能否支持|希望增加|希望新增/, w: 2 },
// weak asks
{ re: /能否|是否可以|可否|能不能|是否能够|希望能|希望可以|请求/, w: 1 },
{ re: /\benhancement\b|\bfeature\b/i, w: 1 },
],
question: [
// comparison/usage questions
{ re: /有什么区别|有什么不同|区别是什么|\bwhat is the difference\b/i, w: 2 },
{ re: /\bhow to\b|\busage\b|\bis it possible\b|\bdoes it support\b|\bquestion\b/i, w: 2 },
// weak question forms
{ re: /为什么/, w: 2 },
{ re: /请问|是否支持|有没有.*(支持|能力)|怎么(用|配置|接入|做)|如何(使用|配置|接入|做)|可以.*吗|能.*吗|对比/, w: 1 },
],
documentation: [
// Treat docs-related words as weaker unless paired with an explicit docs-fix signal.
{ re: /\btypo\b|\bspell(ing)?\b/i, w: 2 },
// Avoid generic "文档" (many issues are about the document product); require a docs-fix context.
{ re: /拼写|文档(错误|修正|修复|补充|改进)|文档.*(缺失|不完整)|安装说明/, w: 2 },
{ re: /\bdocumentation\b|\breadme\b|\bexample\b|\bbest practice\b/i, w: 1 },
{ re: /示例/, w: 1 },
],
performance: [
// Avoid generic "slow" causing false positives (many issues mention slow networks).
{ re: /\bperformance\b|\bperf\b|\bhang\b|\btimeout\b|\blatency\b|\boom\b|10-100x faster|60\+ seconds/i, w: 2 },
{ re: /\bslow\b/i, w: 1 },
{ re: /慢|卡住|超时|高内存|响应慢|耗时/, w: 1 },
],
security: [
{ re: /\bvuln\b|\bcve\b|\binjection\b|\btoken exposure\b|\bpermission bypass\b|\bcredential leak\b/i, w: 2 },
{ re: /凭据泄漏|注入|权限绕过|token\s*暴露|密钥泄露/, w: 2 },
],
};
const scores = {};
for (const type of TYPE_LABELS) {
scores[type] = 0;
for (const rule of rules[type] || []) {
const re = rule && rule.re;
const w = rule && typeof rule.w === "number" ? rule.w : 1;
if (re && re.test(text)) scores[type] += w;
}
}
if (/^\s*\[bug\]/i.test(titleText) || /^\s*bug[:(]/i.test(titleText)) {
scores.bug += 3;
}
if (/^\s*\[(feature|feature request)\]/i.test(titleText) || /\bfeature request\b/i.test(titleText) || /^\s*feat[:(]/i.test(titleText)) {
scores.enhancement += 3;
}
if (/^\s*[【\[]\s*(feature|需求|功能)\s*[】\]]/.test(titleText)) {
scores.enhancement += 3;
}
// Common Chinese feature request prefixes.
if (/^\s*(功能请求|需求)[:]/.test(titleText) || /\bfeature\b[:]/i.test(titleText)) {
scores.enhancement += 3;
}
if (/希望支持|能否支持|是否可以/.test(titleText)) {
scores.enhancement += 1;
}
if (/^\s*\[doc\]/i.test(titleText)) {
scores.documentation += 4;
// If user explicitly marks it as a documentation issue, reduce the chance of mislabeling it as a bug.
if (scores.bug > 0) scores.bug = Math.max(0, scores.bug - 3);
}
if (/^request\b/i.test(titleText)) {
scores.enhancement += 3;
}
return scores;
}
/**
* Choose the highest-scoring type using the configured tie breaker.
*
* @param {Record<string, number>} scores
* @returns {string|null}
*/
function chooseTypeFromScores(scores) {
const entries = TYPE_LABELS.map((t) => ({ t, v: (scores && scores[t]) || 0 }))
.sort((a, b) => b.v - a.v);
const top = entries[0] || { t: null, v: 0 };
const second = entries[1] || { t: null, v: 0 };
if (top.v < TYPE_MIN_SCORE) return null;
// Ambiguous: top two are too close.
if (top.v - second.v < TYPE_MIN_MARGIN) return null;
// Preserve deterministic choice when multiple labels have same score (should be rare after margin check).
const candidates = TYPE_LABELS.filter((t) => (scores && scores[t]) === top.v);
if (candidates.length === 1) return candidates[0];
for (const t of TYPE_TIE_BREAKER) {
if (candidates.includes(t)) return t;
}
return candidates[0] || null;
}
/**
* Classify issue text into one type label and zero or more domains.
*
* @param {string} title
* @param {string} body
* @returns {{type: string|null, domains: string[]}}
*/
function classifyIssueText(title, body) {
const scores = scoreTypeFromText(title, body);
const type = chooseTypeFromScores(scores);
const domains = collectDomainsFromText(title, body);
return { type, domains };
}
/**
* Format a GitHub issue reference for logs.
*
* @param {string} repo
* @param {number} number
* @returns {string}
*/
function formatIssueRef(repo, number) {
return `${repo}#${number}`;
}
/**
* Minimal GitHub REST client for issue labeling operations.
*/
class GitHubClient {
/**
* @param {string} token
* @param {string} repo
*/
constructor(token, repo) {
this.token = token;
this.repo = repo;
}
/**
* Build standard GitHub API headers.
*
* @param {boolean} hasBody
* @returns {Record<string, string>}
*/
buildHeaders(hasBody = false) {
const headers = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
};
if (this.token) headers.Authorization = `Bearer ${this.token}`;
if (hasBody) headers["Content-Type"] = "application/json";
return headers;
}
/**
* Execute a GitHub API request with retry and rate-limit handling.
*
* @param {string} endpoint
* @param {{method?: string, payload?: any, allow404?: boolean, retry?: number}} options
* @returns {Promise<any>}
*/
async request(endpoint, options = {}) {
const {
method = "GET",
payload,
allow404 = false,
retry = 5,
} = options;
const hasBody = payload !== undefined;
const url = endpoint.startsWith("http") ? endpoint : `${API_BASE}${endpoint}`;
for (let attempt = 0; attempt <= retry; attempt += 1) {
const response = await fetch(url, {
method,
headers: this.buildHeaders(hasBody),
body: hasBody ? JSON.stringify(payload) : undefined,
});
if (allow404 && response.status === 404) return null;
const text = await response.text();
const remaining = toInt(response.headers.get("x-ratelimit-remaining"), -1);
const reset = toInt(response.headers.get("x-ratelimit-reset"), -1);
const retryAfter = toInt(response.headers.get("retry-after"), -1);
const lower = String(text || "").toLowerCase();
const isSecondary = lower.includes("secondary rate") || lower.includes("abuse detection");
if (response.ok) {
return text ? JSON.parse(text) : null;
}
const canRetry = attempt < retry;
if (!canRetry) {
const error = new Error(`GitHub API ${method} ${url} failed: ${response.status} ${text}`);
error.status = response.status;
throw error;
}
// Rate-limit handling
if (response.status === 429 || isSecondary) {
const waitMs = retryAfter > 0
? retryAfter * 1000
: isSecondary
? 60_000
: (attempt + 1) * 1000;
await sleep(waitMs);
continue;
}
if (response.status === 403 && remaining === 0 && reset > 0) {
const nowSec = Math.floor(Date.now() / 1000);
const waitMs = Math.max(1, reset - nowSec + 1) * 1000;
await sleep(waitMs);
continue;
}
// transient-ish failures
if (response.status >= 500) {
await sleep((attempt + 1) * 500);
continue;
}
const error = new Error(`GitHub API ${method} ${url} failed: ${response.status} ${text}`);
error.status = response.status;
throw error;
}
throw new Error(`unreachable: request retry loop exceeded for ${method} ${url}`);
}
/**
* Search for currently unlabeled issues in the repository.
*
* This is intentionally a one-shot triage pass: once any label is added, the
* issue falls out of scope for future scheduled runs.
*
* @param {{state?: string, maxPages?: number, maxIssues?: number}} params
* @returns {Promise<any[]>}
*/
async searchUnlabeledIssues(params) {
const issues = [];
const {
state = "open",
maxPages = 10,
maxIssues = 300,
} = params || {};
const qualifiers = [
`repo:${this.repo}`,
"is:issue",
"no:label",
state === "all" ? "" : `state:${state}`,
].filter(Boolean);
const q = qualifiers.join(" ");
for (let page = 1; page <= maxPages; page += 1) {
const search = new URLSearchParams({
q,
sort: "updated",
order: "desc",
per_page: "100",
page: String(page),
});
const result = await this.request(`/search/issues?${search}`);
const batch = result && Array.isArray(result.items) ? result.items : [];
if (batch.length === 0) break;
for (const item of batch) {
issues.push(item);
if (issues.length >= maxIssues) break;
}
if (issues.length >= maxIssues) break;
if (batch.length < 100) break;
}
return issues;
}
/**
* List all repository labels needed for managed-label checks.
*
* @returns {Promise<any[]>}
*/
async listRepositoryLabels() {
const labels = [];
for (let page = 1; page <= 10; page += 1) {
const search = new URLSearchParams({
per_page: "100",
page: String(page),
});
const batch = await this.request(`/repos/${this.repo}/labels?${search}`);
if (!batch || batch.length === 0) break;
labels.push(...batch);
if (batch.length < 100) break;
}
return labels;
}
/**
* Return managed labels that are not currently present in the repository.
*
* @returns {Promise<string[]>}
*/
async listMissingManagedLabels() {
const existing = new Set((await this.listRepositoryLabels()).map((label) => label && label.name));
return MANAGED_LABELS.filter((name) => !existing.has(name));
}
/**
* Add one or more labels to an issue.
*
* @param {number} issueNumber
* @param {string[]} labels
* @returns {Promise<void>}
*/
async addIssueLabels(issueNumber, labels) {
if (!labels || labels.length === 0) return;
await this.request(`/repos/${this.repo}/issues/${issueNumber}/labels`, {
method: "POST",
payload: { labels },
});
}
/**
* Remove a single label from an issue.
*
* @param {number} issueNumber
* @param {string} name
* @returns {Promise<void>}
*/
async removeIssueLabel(issueNumber, name) {
await this.request(`/repos/${this.repo}/issues/${issueNumber}/labels/${encodeURIComponent(name)}`, {
method: "DELETE",
allow404: true,
});
}
}
/**
* Compute label mutations for the current issue state.
*
* @param {{currentLabels: Set<string>|string[], desiredType: string|null, desiredDomainLabels: string[], syncDomains: boolean, overrideType: boolean}} params
* @returns {{toAdd: string[], toRemove: string[]}}
*/
function planIssueLabelChanges(params) {
const {
currentLabels,
desiredType,
desiredDomainLabels,
syncDomains,
overrideType,
} = params;
const current = currentLabels instanceof Set ? currentLabels : new Set(currentLabels || []);
const toAdd = new Set();
const toRemove = new Set();
// Type: only apply when desiredType exists.
// Safety: by default, do NOT override existing type labels to avoid reverting manual triage.
if (desiredType) {
const currentType = [...current].filter((l) => TYPE_LABEL_SET.has(l));
const shouldApplyType = overrideType || currentType.length === 0;
if (shouldApplyType) {
if (!current.has(desiredType)) {
toAdd.add(desiredType);
}
for (const t of currentType) {
if (t !== desiredType) toRemove.add(t);
}
}
}
// Domain: add-only by default; strict sync via --sync-domains.
const desiredDomains = new Set(desiredDomainLabels || []);
for (const d of desiredDomains) {
if (!current.has(d)) toAdd.add(d);
}
// Safety: only remove domains when we can positively match at least one domain.
if (syncDomains && desiredDomains.size > 0) {
for (const d of current) {
if (DOMAIN_LABEL_SET.has(d) && !desiredDomains.has(d)) {
toRemove.add(d);
}
}
}
return {
toAdd: [...toAdd],
toRemove: [...toRemove],
};
}
/**
* Parse CLI arguments into runtime options.
*
* @param {string[]} argv
* @returns {{dryRun: boolean, json: boolean, token: string, repo: string, maxPages: number, maxIssues: number, onlyMissing: boolean, syncDomains: boolean, overrideType: boolean, state: string, help?: boolean}}
*/
function parseArgs(argv) {
const args = {
dryRun: false,
json: false,
token: "",
repo: "",
maxPages: 10,
maxIssues: 300,
onlyMissing: true,
syncDomains: false,
overrideType: false,
state: "open",
};
let i = 0;
function readFlagValue(flag) {
const value = argv[i + 1];
if (value === undefined || String(value).startsWith("-")) {
throw new Error(`missing value for ${flag}`);
}
i += 1;
return String(value);
}
for (; i < argv.length; i += 1) {
const a = argv[i];
if (a === "--help" || a === "-h") {
args.help = true;
continue;
}
if (a === "--dry-run") {
args.dryRun = true;
continue;
}
if (a === "--json") {
args.json = true;
continue;
}
if (a === "--token") {
args.token = readFlagValue("--token");
continue;
}
if (a === "--repo") {
args.repo = readFlagValue("--repo");
continue;
}
if (a === "--max-pages") {
args.maxPages = toInt(readFlagValue("--max-pages"), args.maxPages);
continue;
}
if (a === "--max-issues") {
args.maxIssues = toInt(readFlagValue("--max-issues"), args.maxIssues);
continue;
}
if (a === "--process-all") {
args.onlyMissing = false;
continue;
}
if (a === "--only-missing") {
args.onlyMissing = true;
continue;
}
if (a === "--sync-domains") {
args.syncDomains = true;
continue;
}
if (a === "--override-type") {
args.overrideType = true;
continue;
}
if (a === "--state") {
args.state = readFlagValue("--state");
continue;
}
throw new Error(`unknown argument: ${a}`);
}
return args;
}
/**
* Print CLI help text.
*
* @returns {void}
*/
function printHelp() {
const msg = `Usage: node scripts/issue-labels/index.js [options]
Options:
--dry-run Do not write labels
--json Output JSON (useful with --dry-run)
--repo <owner/name> Override GITHUB_REPOSITORY
--token <token> Override GITHUB_TOKEN
--max-pages <n> Max search result pages to scan (default: 10)
--max-issues <n> Max unlabeled issues to process (default: 300)
--only-missing Only write when changes are needed (default)
--process-all Evaluate all fetched unlabeled issues
--sync-domains Strictly sync domain/* (remove stale) when domain matched
--override-type Override existing type labels (default: false)
--state open|all Issue state to scan (default: open)
`;
console.log(msg);
}
/**
* Entry point for the issue labeler CLI.
*
* @returns {Promise<void>}
*/
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printHelp();
return;
}
const token = args.token || envOrFail("GITHUB_TOKEN");
const repo = args.repo || envOrFail("GITHUB_REPOSITORY");
const client = new GitHubClient(token, repo);
const missingManagedLabels = new Set(await client.listMissingManagedLabels());
const scanned = await client.searchUnlabeledIssues({
state: args.state,
maxPages: args.maxPages,
maxIssues: args.maxIssues,
});
const results = {
repo,
dryRun: args.dryRun,
query: "unlabeled issues (intentional one-shot scope)",
scanned: 0,
skippedPR: 0,
skippedIssue: 0,
updated: 0,
changes: [],
};
for (const issue of scanned) {
results.scanned += 1;
if (issue && issue.pull_request) {
results.skippedPR += 1;
continue;
}
const currentLabels = new Set((issue.labels || []).map((l) => l.name));
const { type: desiredType, domains } = classifyIssueText(issue.title, issue.body);
const desiredDomainLabels = domains.map((d) => `domain/${d}`);
const { toAdd, toRemove } = planIssueLabelChanges({
currentLabels,
desiredType,
desiredDomainLabels,
syncDomains: args.syncDomains,
overrideType: args.overrideType,
});
// If some managed labels do not exist in the repository, drop only those labels
// (still apply the rest) instead of skipping the entire issue.
const missingForIssue = toAdd.filter((name) => missingManagedLabels.has(name));
const effectiveToAdd = missingForIssue.length > 0
? toAdd.filter((name) => !missingManagedLabels.has(name))
: toAdd;
if (missingForIssue.length > 0) {
const warning = `warning: ${formatIssueRef(repo, issue.number)} missing labels in ${repo}: ${missingForIssue.join(", ")}`;
console.warn(warning);
}
const hasChange = effectiveToAdd.length > 0 || toRemove.length > 0;
// When --only-missing is enabled (default), we still want JSON output to reflect
// issues that were "actionable" only by missing repo labels.
if (args.onlyMissing && !hasChange) {
if (args.json && missingForIssue.length > 0) {
results.skippedIssue += 1;
results.changes.push({
issue: {
number: issue.number,
title: issue.title,
url: issue.html_url,
},
desired: {
type: desiredType,
domains,
},
change: { toAdd: [], toRemove: [] },
skipped: true,
reason: "missing_managed_labels",
missingLabels: missingForIssue,
});
}
continue;
}
const record = {
issue: {
number: issue.number,
title: issue.title,
url: issue.html_url,
},
desired: {
type: desiredType,
domains,
},
change: { toAdd: effectiveToAdd, toRemove },
};
if (missingForIssue.length > 0) {
record.missingLabels = missingForIssue;
}
if (args.json) {
results.changes.push(record);
} else {
console.log(`[${formatIssueRef(repo, issue.number)}] +${effectiveToAdd.join(", ") || "-"} -${toRemove.join(", ") || "-"}`);
}
if (!args.dryRun) {
// Add first to avoid leaving a temporary empty state.
if (effectiveToAdd.length > 0) {
await client.addIssueLabels(issue.number, effectiveToAdd);
}
for (const name of toRemove) {
await client.removeIssueLabel(issue.number, name);
}
}
if (hasChange) {
results.updated += 1;
}
}
if (args.json) {
console.log(JSON.stringify(results));
} else {
console.log(`done: scanned=${results.scanned} updated=${results.updated} skipped_pr=${results.skippedPR} skipped_issue=${results.skippedIssue}`);
}
}
if (require.main === module) {
main().catch((err) => {
console.error(err && err.stack ? err.stack : String(err));
process.exit(1);
});
}
module.exports = {
classifyIssueText,
collectDomainsFromText,
scoreTypeFromText,
chooseTypeFromScores,
planIssueLabelChanges,
TYPE_LABELS,
DOMAIN_SERVICES,
};
+617
View File
@@ -0,0 +1,617 @@
[
{
"name": "#234 现在使用必须要创建应用吗?",
"title": "现在使用必须要创建应用吗?",
"body": "能不能支持使用个人身份呢?",
"expected_type": "enhancement",
"expected_domains": [],
"source_url": "https://github.com/larksuite/cli/issues/234",
"source_labels": []
},
{
"name": "#240 查看妙记需要导出权限, 但是却没有申请导出权限的地方",
"title": "查看妙记需要导出权限, 但是却没有申请导出权限的地方",
"body": "现在整体让 AI 读取妙记的流程, 是有 bug 的.\n\n* 首先, binger-lark-api 读取秒记(比如 https://larksuite.com/minutes/obusrsm7fp44doce3rss4864), 需要有导出权限, AI 才能够读取(这儿有点奇怪, 人能够读取, 但是 AI 需要额外权限才能读取, cli 的重要目标就是人能读的,AI 也要能读)\n* ok, 那我去申请导出权限, 但是秒记右上角的权限申请, 只有\"申请编辑权限\", 无法申请导出权限\n\n建议解决方案:\n\n1. 所有秒记, 默认有阅读权限, 就有导出权限. 即 阅读权限=导出权限. (本来能在 web 页面上读取, 那么手动复制也是一种\"导出\"方式)\n2. 为秒记增加申请权限设置, 增加按钮\"申请导出权限\"\n3. 提供 open-api 能够发起\"申请导出权限\"",
"expected_type": "bug",
"expected_domains": [
"minutes"
],
"source_url": "https://github.com/larksuite/cli/issues/240",
"source_labels": []
},
{
"name": "#244 安装脚本没有给 Trae CN 安装 skills",
"title": "安装脚本没有给 Trae CN 安装 skills",
"body": "Trae CN 的 skills 位置在:`~/.trae-cn/skills`,希望可以加入检测",
"expected_type": "bug",
"expected_domains": [
"core"
],
"source_url": "https://github.com/larksuite/cli/issues/244",
"source_labels": []
},
{
"name": "#201 postinstall: binary download fails silently behind HTTP proxy (Node.js https ign",
"title": "postinstall: binary download fails silently behind HTTP proxy (Node.js https ignores https_proxy)",
"body": "## Environment\n\n- **OS**: macOS 26.3.1 (Darwin 25.3.0) arm64\n- **lark-cli version**: v1.0.2 (also reproduced on v1.0.0)\n- **Node.js**: v25.8.2\n- **npm**: 11.11.1\n- **Installation method**: npm global install\n- **Network**: Behind HTTP proxy (https_proxy=http://127.0.0.1:7897)\n\n## Description\n\n`npm install -g @larksuite/cli` completes without error, but the `bin/` directory is empty — the Go binary is never downloaded. The postinstall script (`scripts/install.js`) uses Node.js native `https.get()` which does **not** honor the `https_proxy` / `HTTP_PROXY` environment variables. In mainland China (and other regions where GitHub is slow or blocked), this causes the download to fail silently.\n\n`curl` and `wget` in the same shell session work fine because they respect proxy environment variables.\n\n## Steps to Reproduce\n\n1. Set an HTTP proxy:\n ```bash\n export https_proxy=http://127.0.0.1:7897\n ```\n\n2. Install lark-cli:\n ```bash\n npm install -g @larksuite/cli\n ```\n\n3. Check the binary:\n ```bash\n ls $(npm root -g)/@larksuite/cli/bin/\n # Empty directory — no binary\n ```\n\n4. Verify curl works with the same proxy:\n ```bash\n curl -fSL -o /tmp/lark-cli.tar.gz \\\n \"http\n\n...(truncated)",
"expected_type": "bug",
"expected_domains": [
"core"
],
"source_url": "https://github.com/larksuite/cli/issues/201",
"source_labels": [
"bug"
]
},
{
"name": "#239 从命令行创建markdown不太可行",
"title": "从命令行创建markdown不太可行",
"body": "[与Agent的聊天记录](https://app.lobehub.com/share/t/XeiXXDP1)\n\n它试图运行\n\n``` shell\nlark-cli docs +create --title \"Hello Lobe\" --markdown \"## 欢迎使用飞书文档\\n\\n这是一个由 Lobe 通过飞书 CLI 自动生成的文档。\\n\\n### 功能测试\\n- [x] 文档创建\\n- [ ] 更多功能探索\\n\\n<callout emoji=\\\"💡\\\" background-color=\\\"light-blue\\\">\\n提示:你可以通过飞书 CLI 轻松管理你的云空间文档。\\n</callout>\"\n```\n\n但是,空格会被截断,换行也不能正确换行\n\n这个是在消耗了500K token之后给出的方案(在本地编写了一个js再运行):\n\n``` js\nconst { spawnSync } = require('child_process');\n\nconst title = \"Lobe 完美传输测试\";\nconst markdown = `## 飞书文档集成测试 ✅\n\n这是一个通过 **Node.js 子进程 (shell: false)** 传输的文档。这次我们完全禁用了 Shell 解释器,由操作系统直接通过命令行数组分发参数。\n\n### 为什么这个有效?\n由于我们禁用了 shell,操作系统会直接把这些带空格的字符串塞给进程,而不需要通过引号转义处理。\n\n<callout emoji=\"🚀\" background-color=\"light-green\">\n成功发送!空格和引号截断问题已彻底解决。\n</callout>\n`;\n\n// 使用全路径直接运行 node 和 cli 脚本,完全避免 shell 解析\nconst nodePath = process.execPath;\nconst cliScript = 'C:\\\\nvm4w\\\\nodejs\\\\node_modules\\\\@larksuite\\\\cli\\\\scripts\\\\run.js';\n\nconst result = spawnSync(nodePath, [cliScript, 'docs', '+create', '--title', title, '--markdown', markdown], {\n encoding: 'utf8',\n shell: false // 关键:禁用 Shell\n});\n\nconsole.log(result.stdout || result.stderr);\n\n```",
"expected_type": null,
"expected_domains": [
"doc"
],
"source_url": "https://github.com/larksuite/cli/issues/239",
"source_labels": []
},
{
"name": "#204 CLI的多用户授权支持需求",
"title": "CLI的多用户授权支持需求",
"body": "当支持多用户的Agent使用CLI时,无法根据openid对不同用户进行授权管理,可能与当前CLI针对个人助理方向有关。但这对企业级多用户Agent也是非常广泛的和有价值需求,希望增加不同openid的授权模式功能。",
"expected_type": "enhancement",
"expected_domains": [
"auth",
"core"
],
"source_url": "https://github.com/larksuite/cli/issues/204",
"source_labels": []
},
{
"name": "#122 lark-cli api POST silently fails with --as user (exit 1, no output)",
"title": "lark-cli api POST silently fails with --as user (exit 1, no output)",
"body": "## Description\n\n`lark-cli api POST` silently fails when using `--as user` identity. The command exits with code 1 but produces **no stdout and no stderr output**, making it impossible to debug.\n\n## Environment\n\n- lark-cli version: 1.0.0\n- OS: macOS (Darwin 25.4.0, arm64)\n- Auth: user token valid, scope `im:message.send_as_user` confirmed granted\n\n## Steps to Reproduce\n\n```bash\n# This works fine (GET with user identity):\nlark-cli api GET /open-apis/im/v1/chats --as user\n# Returns JSON response correctly\n\n# This silently fails (POST with user identity):\nlark-cli api POST /open-apis/im/v1/messages \\\n --params '{\"receive_id_type\":\"open_id\"}' \\\n --data '{\"receive_id\":\"ou_xxx\",\"msg_type\":\"text\",\"content\":\"{\\\"text\\\":\\\"hello\\\"}\"}' \\\n --as user\n# Exit code 1, NO stdout, NO stderr\n```\n\n## Expected Behavior\n\nShould either:\n1. Successfully send the message and return the API response, or\n2. Print an error message explaining why it failed\n\n## Actual Behavior\n\n- Exit code: 1\n- stdout: empty\n- stderr: empty\n\n## Additional Context\n\n- `--dry-run` works correctly and shows the expected request structure\n- `lark-cli api GET` with `--as user` works fine\n- `lark-cli api POST` with `--as bot` (via sh\n\n...(truncated)",
"expected_type": "bug",
"expected_domains": [
"auth",
"im"
],
"source_url": "https://github.com/larksuite/cli/issues/122",
"source_labels": []
},
{
"name": "#123 base +record-upsert returns 800010701 \"Invalid input\" for any non-empty field",
"title": "base +record-upsert returns 800010701 \"Invalid input\" for any non-empty fields (POST with user identity broken)",
"body": "## Description \n \n `lark-cli base +record-upsert` returns error `800010701 (Invalid input)` \n whenever the `fields` object is non-empty, even with correct JSON format \n matching the table schema. \n \n ## Environment \n\n - lark-cli version: 1.0.0\n - OS: macOS (Darwin 25.x)\n - Auth: user token valid, all base scopes confirmed granted\n (`base:record:create`, `base:record:update`, etc.) \n \n ## Steps to Reproduce \n \n 1. `lark-cli base +base-create --name \"Test\"` → success \n 2. `lark-cli base +table-create --base-token <token> --name \"Test\"` → success\n (creates table with auto ID field) \n 3. `lark-cli base +field-cre\n\n...(truncated)",
"expected_type": "bug",
"expected_domains": [
"base"
],
"source_url": "https://github.com/larksuite/cli/issues/123",
"source_labels": [
"domain/base"
]
},
{
"name": "#200 希望支持多维表格设置 字段默认值(default_value)和字段描述(description",
"title": "希望支持多维表格设置 字段默认值(default_value)和字段描述(description",
"body": "经过实操验证\n❌ 不支持(需手动在客户端/网页端设置):\n- 字段默认值\n- 字段描述/备注\n\n还有整理字段顺序,现在配错要么删除重新,要么手动调整顺序。",
"expected_type": "enhancement",
"expected_domains": [
"base"
],
"source_url": "https://github.com/larksuite/cli/issues/200",
"source_labels": [
"domain/base"
]
},
{
"name": "#89 无法以用户的名义发送消息,只能以bot的名义,还有操作多维表格的时候,插入的格式以及插入的地方,不能够准确插入",
"title": "无法以用户的名义发送消息,只能以bot的名义,还有操作多维表格的时候,插入的格式以及插入的地方,不能够准确插入",
"body": "",
"expected_type": "bug",
"expected_domains": [
"base",
"im"
],
"source_url": "https://github.com/larksuite/cli/issues/89",
"source_labels": [
"domain/base",
"domain/im"
]
},
{
"name": "#18 为什么代码层面写死了只能以 bot 身份发",
"title": "为什么代码层面写死了只能以 bot 身份发",
"body": "",
"expected_type": "question",
"expected_domains": [],
"source_url": "https://github.com/larksuite/cli/issues/18",
"source_labels": []
},
{
"name": "#225 Riscv64 is not supported",
"title": "Riscv64 is not supported",
"body": "`npm install -g @larksuite/cli\nnpm error code EBADPLATFORM\nnpm error notsup Unsupported platform for @larksuite/cli@1.0.2: wanted {\"os\":\"darwin,linux,win32\",\"cpu\":\"x64,arm64\"} (current: {\"os\":\"linux\",\"cpu\":\"riscv64\"})\nnpm error notsup Valid os: darwin,linux,win32\nnpm error notsup Actual os: linux\nnpm error notsup Valid cpu: x64,arm64\nnpm error notsup Actual cpu: riscv64`",
"expected_type": "bug",
"expected_domains": [
"core"
],
"source_url": "https://github.com/larksuite/cli/issues/225",
"source_labels": []
},
{
"name": "#222 动态选项字段无法通过 API 写入",
"title": "动态选项字段无法通过 API 写入",
"body": "在使用 lark-cli base +record-upsert 写入飞书多维表格记录时,部分字段无法通过 API 写入,但在浏览器 UI 中可以正常操作。受影响的字段类型在 API 中显示为 not_support 类型,无法写入:\n\n相关日志\n\n # 字段列表返回\n $ lark-cli base +field-list --base-token xxx --table-id xxx\n\n # 返回结果中字段类型为 not_support\n {\n \"field_id\": \"fld9P55tkb\",\n \"field_name\": \"工号\",\n \"type\": \"not_support\"\n }\n\n # 写入记录时\n $ lark-cli base +record-upsert --base-token xxx --table-id xxx --json '{\"工号\": \"F1336477\"}'\n\n # 字段被忽略\n {\n \"ignored_fields\": [\n {\n \"id\": \"fld9P55tkb\",\n \"name\": \"工号\",\n \"reason\": \"UNSUPPORTED: select field with dynamic options cannot be written through OpenAPI.\"\n }\n ]\n }",
"expected_type": "bug",
"expected_domains": [
"base"
],
"source_url": "https://github.com/larksuite/cli/issues/222",
"source_labels": []
},
{
"name": "#215 bug(api): Drive folder raw APIs silently fail with exit code 1 and no output",
"title": "bug(api): Drive folder raw APIs silently fail with exit code 1 and no output",
"body": "## Summary\n\nWhen using `lark-cli api` to call Drive folder-related raw APIs, the CLI exits with code `1` but prints no stdout and no stderr.\n\nThis makes it impossible to debug or build folder automation on top of Lark CLI raw API.\n\n## Environment\n\n- CLI: `@larksuite/cli`\n- Install method: global npm install\n- OS: macOS arm64\n- Identity: `--as user`\n- Authentication: valid user login\n- Related shortcut commands such as `lark-cli drive +upload` work correctly in the same environment\n\n## Affected raw APIs\n\nThe following official APIs are documented and can be composed correctly by `--dry-run`, but fail silently on real execution.\n\n### List items in folder\n\n```bash\nlark-cli api GET /open-apis/drive/v1/files \\\n --as user \\\n --format json \\\n --params '{\"folder_token\":\"<FOLDER_TOKEN>\",\"page_size\":100}'\n```\n\n### Create folder\n\n```bash\nlark-cli api POST /open-apis/drive/v1/files/create_folder \\\n --as user \\\n --format json \\\n --data '{\"name\":\"analysis-test\",\"folder_token\":\"<FOLDER_TOKEN>\"}'\n```\n\n## Reproduction steps\n\n1. Ensure `lark-cli auth login` is complete and user auth is valid.\n2. Use a folder token that is confirmed accessible under the same account.\n3. Run:\n\n```bash\nlark-cli a\n\n...(truncated)",
"expected_type": "bug",
"expected_domains": [
"auth",
"drive"
],
"source_url": "https://github.com/larksuite/cli/issues/215",
"source_labels": []
},
{
"name": "#175 lark-cli api POST /open-apis/im/v1/messages --as user 静默失败(exit code 1,无输出)",
"title": "lark-cli api POST /open-apis/im/v1/messages --as user 静默失败(exit code 1,无输出)",
"body": "## 问题描述\n\n使用 `lark-cli api` 以 user 身份调用 `POST /open-apis/im/v1/messages` 发送消息时,命令以 exit code 1 退出,但 **stdout 和 stderr 均无任何输出**,无法定位失败原因。\n\n## 复现步骤\n\n### 1. 环境信息\n\n- lark-cli version: **1.0.0**\n- OS: macOS (Darwin 25.3.0)\n- 已通过 `lark-cli auth login --scope \"im:message\"` 完成用户授权\n- `lark-cli auth status` 显示 token 有效,scope 包含 `im:message`\n- `lark-cli auth check --scope \"im:message\"` 返回 granted\n\n### 2. 复现命令\n\n```bash\nlark-cli api POST /open-apis/im/v1/messages \\\n --as user \\\n --params '{\"receive_id_type\":\"chat_id\"}' \\\n --data '{\"receive_id\":\"oc_xxx\",\"msg_type\":\"text\",\"content\":\"{\\\"text\\\":\\\"test\\\"}\"}'\n```\n\n### 3. 实际结果\n\n命令直接以 exit code 1 退出,stdout 和 stderr **均无输出**\n\n```bash\n$ lark-cli api POST /open-apis/im/v1/messages --as user \\\n --params '{\"receive_id_type\":\"chat_id\"}' \\\n --data '{\"receive_id\":\"oc_xxx\",\"msg_type\":\"text\",\"content\":\"{\\\"text\\\":\\\"test\\\"}\"}' \\\n 2>&1; echo \"EXIT:$?\"\nEXIT:1\n```\n\n### 4. 期望结果\n\n- 如果 API 支持 user_access_token:应成功发送消息并返回 JSON 响应\n- 如果 API 不支持 user_access_token:应输出明确的错误信息(如 `\"error\": {\"type\": \"unsupported_token\", ...}`),而非静默退出\n\n## 排查过程\n\n| 测试 | 结果 |\n|------|------|\n| `--dry-run` 预览请求 | ✅ 正常输出,请求结构正确 |\n| `lark-cli api GET /open-apis/im/v1/chats --as user` | ✅ 正常\n\n...(truncated)",
"expected_type": "bug",
"expected_domains": [
"auth",
"im"
],
"source_url": "https://github.com/larksuite/cli/issues/175",
"source_labels": []
},
{
"name": "#129 期望支持 token 直传",
"title": "期望支持 token 直传",
"body": "CLI 封装好了命令,不想重复造轮子,能支持多用户并行使用就太好了。",
"expected_type": null,
"expected_domains": [
"core"
],
"source_url": "https://github.com/larksuite/cli/issues/129",
"source_labels": []
},
{
"name": "#137 需要开通授权的权限不支持部分勾选",
"title": "需要开通授权的权限不支持部分勾选",
"body": "lark-cli auth login --recommend,执行这一步提取授权链接发送给管理员审批;\n和文档知识库有关的权限太多了,尤其是删除,编辑相关权限,我只想要授权文档查看的权限",
"expected_type": null,
"expected_domains": [
"auth",
"doc",
"wiki"
],
"source_url": "https://github.com/larksuite/cli/issues/137",
"source_labels": []
},
{
"name": "#209 [Feature request] The read and post of comments with user info within a feishu d",
"title": "[Feature request] The read and post of comments with user info within a feishu document",
"body": "As the title stated, I want a cli command to access the comments posted by other users within the feishu document. And it's better to have another command to response comments by agents. \n\nWhen converting the feishu document into markdown format, the comments could be represented by a speical grammar, like [userA]: <> (This is a comment.)",
"expected_type": "enhancement",
"expected_domains": [
"doc"
],
"source_url": "https://github.com/larksuite/cli/issues/209",
"source_labels": []
},
{
"name": "#208 lark-cli im +messages-send -markdown xxx 的时候能否支持 表格语法?",
"title": "lark-cli im +messages-send -markdown xxx 的时候能否支持 表格语法?",
"body": "我发送的表格内容似乎都会丢失,如果想发送表格类内容有什么简单的方法嘛, 富文本的json需要转换,没有AI原生输出 markdown丝滑",
"expected_type": "enhancement",
"expected_domains": [
"im"
],
"source_url": "https://github.com/larksuite/cli/issues/208",
"source_labels": []
},
{
"name": "#52 不支持以自己的机器人应用创建会议吗?",
"title": "不支持以自己的机器人应用创建会议吗?",
"body": "我执行命令后使用的是默认的飞书 CLI应用?",
"expected_type": null,
"expected_domains": [
"vc"
],
"source_url": "https://github.com/larksuite/cli/issues/52",
"source_labels": []
},
{
"name": "#17 Feature Request: Add minutes list command to browse all minutes",
"title": "Feature Request: Add minutes list command to browse all minutes",
"body": "## Summary\n\nCurrently `lark-cli minutes minutes get` only supports fetching a single minute by `minute_token`. There is no way to **list or search** minutes via the CLI.\n\n## Use Case\n\nAs a user, I want to browse my minutes records from the CLI or through an AI agent, without needing to know the exact `minute_token` in advance.\n\nFor example:\n```bash\n# Desired: list my recent minutes\nlark-cli minutes minutes list\n\n# Desired: search minutes by keyword\nlark-cli minutes minutes list --params '{\"keyword\": \"周会\"}'\n```\n\n## Current Behavior\n\n- `lark-cli minutes minutes` only has `get` subcommand\n- `get` requires a `minute_token` parameter, which can only be obtained from the Feishu web UI\n- No list/search command is available\n\n## Expected Behavior\n\nAdd a `list` command (or a `+` shortcut like `+list`) that returns the user's minutes, ideally with pagination and optional filters (date range, keyword, etc.).\n\n## Environment\n\n- lark-cli installed via `npm install -g @larksuite/cli`\n- OS: Linux",
"expected_type": "enhancement",
"expected_domains": [
"minutes"
],
"source_url": "https://github.com/larksuite/cli/issues/17",
"source_labels": []
},
{
"name": "#76 飞书auth login支持自定义权限,或者auth机器人已申请的权限",
"title": "飞书auth login支持自定义权限,或者auth机器人已申请的权限",
"body": "lark-cli auth login 的生成的链接不支持自定义权限,如移除部分权限,也无法批量auth机器人已经拥有的权限。\n建议支持仅auth已拥有的权限,或者批量导入json权限配置",
"expected_type": "enhancement",
"expected_domains": [
"auth"
],
"source_url": "https://github.com/larksuite/cli/issues/76",
"source_labels": []
},
{
"name": "#203 ⚠️ 过程状态卡片更新失败,已降级为独立结果消息",
"title": "⚠️ 过程状态卡片更新失败,已降级为独立结果消息",
"body": "<img width=\"596\" height=\"117\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/85060bfc-a914-4bad-a600-e91d64a03174\" />",
"expected_type": null,
"expected_domains": [
"im"
],
"source_url": "https://github.com/larksuite/cli/issues/203",
"source_labels": []
},
{
"name": "#64 Windows: `--params` JSON parsing fails for `im messages read_users` (and other n",
"title": "Windows: `--params` JSON parsing fails for `im messages read_users` (and other non-empty params calls)",
"body": "## Summary\n\nOn Windows, `lark-cli` appears to support `im messages read_users`, but any call that passes a non-empty JSON object to `--params` fails in CLI parsing.\n\nThe Feishu OpenAPI itself works correctly. The same request succeeds in the official API debugger.\n\n## Environment\n\n- OS: Windows\n- Shell: PowerShell\n- CLI version: `lark-cli 1.0.0`\n\n## What works\n\n- `lark-cli im messages read_users --help`\n- `lark-cli schema im.messages.read_users`\n- Feishu OpenAPI debugger successfully calls:\n - `GET /open-apis/im/v1/messages/{message_id}/read_users`\n - with `user_id_type=open_id`\n\n## What fails\n\nThis command is expected to work, but fails with JSON parsing error:\n\n```powershell\nlark-cli im messages read_users --as bot --params '{\"message_id\":\"om_x100b53a771492ca8b4ca3ca7535d2d0\",\"user_id_type\":\"open_id\"}' --format json\n```\n\nActual result:\n\n```json\n{\n \"ok\": false,\n \"identity\": \"bot\",\n \"error\": {\n \"type\": \"validation\",\n \"message\": \"--params invalid JSON format\"\n }\n}\n```\n\nI also observed similar behavior on Windows for other commands when `--params` is a non-empty JSON object.\n\nFor example, even a simple dry-run can fail:\n\n```powershell\nlark-cli api GET /open-apis/calendar/\n\n...(truncated)",
"expected_type": "bug",
"expected_domains": [
"im"
],
"source_url": "https://github.com/larksuite/cli/issues/64",
"source_labels": []
},
{
"name": "#202 im +messages-send --markdown: table syntax and blank lines are silently dropped",
"title": "im +messages-send --markdown: table syntax and blank lines are silently dropped",
"body": "## Environment\n\n- **OS**: macOS 26.3.1 arm64\n- **lark-cli version**: v1.0.2\n- **Shell**: zsh\n\n## Description\n\nWhen sending messages with `--markdown`, two formatting issues occur:\n\n1. **Markdown tables are completely lost** — `| col | col |` syntax produces no output at all, the table content disappears silently\n2. **Blank lines are collapsed** — double newlines `\\n\\n` between paragraphs are compressed to single newlines, removing paragraph spacing\n\n## Steps to Reproduce\n\n```bash\nlark-cli im +messages-send --as bot --chat-id \"oc_xxx\" --markdown '**Test**\n\n| Item | Status |\n|------|--------|\n| Table | Testing |\n\nParagraph 1\n\nParagraph 2 (should have blank line above)'\n```\n\n### Expected output in Feishu\n\n```\n**Test**\n\n| Item | Status |\n|--------|---------|\n| Table | Testing |\n\nParagraph 1\n\nParagraph 2 (should have blank line above)\n```\n\n### Actual output in Feishu\n\n```\nTest\nParagraph 1\nParagraph 2 (should have blank line above)\n```\n\n- Table is completely missing\n- Bold renders correctly\n- All blank lines between paragraphs are gone\n\n## Impact\n\nThis makes `--markdown` unreliable for any structured data output. Users must fall back to `--text` (which preserves blank lines but has n\n\n...(truncated)",
"expected_type": "bug",
"expected_domains": [
"im"
],
"source_url": "https://github.com/larksuite/cli/issues/202",
"source_labels": []
},
{
"name": "#62 Intermittent EOF errors on base commands (+table-list / +record-list / +record-g",
"title": "Intermittent EOF errors on base commands (+table-list / +record-list / +record-get / +record-upsert) during batch operations",
"body": "## Summary\n\nI encountered intermittent `EOF` errors when using `lark-cli` with Base APIs.\n\nThe issue first appeared during batch processing against a Base table, but later even simple read-only commands started failing with the same `EOF` error.\n\nAt the moment Im not sure whether this is caused by `lark-cli`, the upstream Base OpenAPI, or connection handling between them, but Id like to report the behavior because it makes long-running batch jobs unreliable.\n\n## Environment\n\n- `lark-cli` version: `1.0.0`\n- OS: macOS\n- identity: `user`\n\n## Affected commands\n\nI observed `EOF` on multiple commands, including:\n\n- `lark-cli base +table-list`\n- `lark-cli base +record-list`\n- `lark-cli base +record-get`\n- `lark-cli base +record-upsert`\n\n## Example errors\n\n### table-list\n```text\n{\n \"ok\": false,\n \"identity\": \"user\",\n \"error\": {\n \"type\": \"api_error\",\n \"message\": \"API call failed: Get \\\"https://open.feishu.cn/open-apis/base/v3/bases/<base_token>/tables?limit=5&offset=0\\\": EOF\"\n }\n}",
"expected_type": "bug",
"expected_domains": [
"base"
],
"source_url": "https://github.com/larksuite/cli/issues/62",
"source_labels": []
},
{
"name": "#128 一台电脑上能否创建多个飞书 cli?",
"title": "一台电脑上能否创建多个飞书 cli?",
"body": "如题。个人账号和飞书账号都想使用,之前在个人账号创建了一个飞书 cli,想在公司做演示,用公司的账号又创建了一个飞书 cli,但是原来个人账号创建的不见了。家目录的 .lark-cli/config.json 只有一份。",
"expected_type": null,
"expected_domains": [
"core"
],
"source_url": "https://github.com/larksuite/cli/issues/128",
"source_labels": []
},
{
"name": "#124 返回结果全部使用json 太浪费token 了, 最好可以重新设计cli的返回结果.",
"title": "返回结果全部使用json 太浪费token 了, 最好可以重新设计cli的返回结果.",
"body": "cli 返回的结果都是 json 格式的数据 , 其中有很多字段和结果是无关的. \n无论是从格式上还是字段内容上都非常浪费token.",
"expected_type": null,
"expected_domains": [],
"source_url": "https://github.com/larksuite/cli/issues/124",
"source_labels": []
},
{
"name": "#196 建议官方支持 Bun 作为 Node.js 替代方案,提升开发体验",
"title": "建议官方支持 Bun 作为 Node.js 替代方案,提升开发体验",
"body": "## 背景\n目前 larksuite/cli 运行环境依赖 Node.js`engines.node`),实际核心 CLI 是 Go 预编译二进制,通过 JS 脚本(如 `install.js`、`run.js`)进行管理。\n\nBun 是一个新兴的 JavaScript 运行时,兼容 Node.js 大部分 API,具备以下显著优势:\n\n- 启动速度更快,依赖安装与执行体验远优于 Node.js\n- 不需 nvm 等版本隔离工具,减少环境冲突\n- 自带包管理器(等价于 npm/yarn/pnpm\n- 更低的内存和 CPU 占用\n- 更适合 CLI 场景与现代云/AI 环境\n\n目前个人和许多开发者因为多项目需要频繁切换不同 Node 版本、维护 nvm,自从迁移到 Bun 后感到大大减负。而像本项目这样主要用 JS 脚本起 GO 二进制的模式,用 Bun 兼容性极好,且用 `bun run`/`bunx` 启动 CLI 体验非常流畅。\n\n## 建议\n- 在 `package.json` 里添加 `engines.bun` 字段:\n ```json\n \"engines\": {\n \"node\": \">=16\",\n \"bun\": \">=1.0\"\n },\n \"packageManager\": \"bun@latest\"\n ```\n- 脚本部分支持 `bun` 和 `node` 两种运行方式(如 postinstall 可试`bun ... || node ...`),官方 Readme 说明支持 Bun\n- 将 Bun 作为推荐的 CLI 运行方式之一,面向现代开发者和 AI 场景\n\n## 期望效果\n- 降低对特定 Node 版本的依赖,减少 nvm 疲劳\n- CLI 启动速度及依赖安装更快\n- 吸引更多 Bun 用户与下一代 JavaScript 社区\n\n感谢官方团队的付出!很期待能看到官方 Bun 支持或意见!",
"expected_type": "enhancement",
"expected_domains": [
"core"
],
"source_url": "https://github.com/larksuite/cli/issues/196",
"source_labels": []
},
{
"name": "#195 这个和feishu-mcp有什么区别",
"title": "这个和feishu-mcp有什么区别",
"body": "相关链接 https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/mcp_integration/mcp_introduction",
"expected_type": "question",
"expected_domains": [],
"source_url": "https://github.com/larksuite/cli/issues/195",
"source_labels": []
},
{
"name": "#160 【feature】我现在在做 feishu 集成 Agent,发现日历和任务清单有问题",
"title": "【feature】我现在在做 feishu 集成 Agent,发现日历和任务清单有问题",
"body": "# 如题\n我想让Agent把任务记到将任务清单和日历。这样我可以在飞书上很清晰的看到需要做什么事情。\n\n但是现在获取日历的接口,不支持获取任务清单的数据,但是人类在飞书上却可以看到。",
"expected_type": "enhancement",
"expected_domains": [
"calendar",
"task"
],
"source_url": "https://github.com/larksuite/cli/issues/160",
"source_labels": []
},
{
"name": "#161 不能给未授权用户发消息",
"title": "不能给未授权用户发消息",
"body": "lark-cli api POST --data 在 Windows 上有 bug\nlark-cli im +messages-send 只支持 bot 身份\nbot 没有权限给未授权的用户发消息",
"expected_type": "bug",
"expected_domains": [
"auth",
"im"
],
"source_url": "https://github.com/larksuite/cli/issues/161",
"source_labels": []
},
{
"name": "#58 今天使用cli来操作wiki,能力需要补强",
"title": "今天使用cli来操作wiki,能力需要补强",
"body": "在claude code中操作wiki,本来想重构我的wiki知识库的结构、目录之类,但是发现以下能力并没有,希望可以尽快补强\n\n--- \n ❌ 不能做的(缺失的能力)\n \n Wiki 操作缺失\n \n 1. 创建新节点/文档 — 无法在 Wiki 中新建节点 \n 2. 删除节点 — 没有删除 Wiki 节点的 API \n 3. 直接上传文件到 Wiki — 上传只能到 Drive,无法直接上传到 Wiki 知识库 \n \n 集成能力缺失 \n \n 1. Wiki ↔ Drive 自动集成 — 上传文件后无法自动归类到 Wiki 目录 \n 2. 批量操作 — 没有批量移动/创建节点的能力\n---",
"expected_type": "bug",
"expected_domains": [
"doc",
"drive",
"wiki"
],
"source_url": "https://github.com/larksuite/cli/issues/58",
"source_labels": []
},
{
"name": "#189 [Doc] base +record-list 分页读取时字段顺序不稳定的说明与最佳实践",
"title": "[Doc] base +record-list 分页读取时字段顺序不稳定的说明与最佳实践",
"body": "## 问题描述\n\n在使用 `lark-cli base +record-list` 分页读取多维表格数据时,发现一个容易被忽视的问题:\n\n**API 返回的 `field_id_list` 顺序在不同分页中可能不同**\n\n这会导致:\n1. 用硬编码索引定位字段时,第二页数据解析错误\n2. 数据匹配失败,程序误判为\"读取完成\"\n3. 实际数据遗漏,但不会报错\n\n## 复现场景\n\n```python\n# 错误做法 - 假设字段顺序固定\nresult = run_lark_cli(['base', '+record-list', '--offset', '0', '--limit', '500'])\nfields = result['data']['fields']\nid_idx = fields.index('编号') # 第一页:索引可能是 0\n\n# 第二页\nresult = run_lark_cli(['base', '+record-list', '--offset', '500', '--limit', '500'])\nfields = result['data']['fields']\nrecord = result['data']['data'][0]\nsample_id = record[id_idx] # 第二页:索引可能是 11,数据错位!\n```\n\n**实际案例**\n- 表格总记录:254 条\n- 第一页返回:200 条,字段顺序 A\n- 第二页返回:54 条,字段顺序 B(与 A 不同)\n- 结果:第二页数据全部解析错误,匹配失败\n\n## 解决方案\n\n**必须使用 `field_id_list` 定位字段,且每页都要重新计算索引**\n\n```python\nID_FIELD_ID = \"fld64hLsFo\" # 编号字段的 field_id(固定不变)\n\ndef load_all_records(base_token, table_id):\n record_map = {}\n offset = 0\n page_size = 500\n\n while True:\n result = run_lark_cli([\n 'base', '+record-list',\n '--base-token', base_token,\n '--table-id', table_id,\n '--limit', str(page_size),\n '--offset', str(offset)\n ])\n\n data = result.get('data', {})\n fie\n\n...(truncated)",
"expected_type": "documentation",
"expected_domains": [
"base",
"doc"
],
"source_url": "https://github.com/larksuite/cli/issues/189",
"source_labels": []
},
{
"name": "#187 持久化连接授权",
"title": "持久化连接授权",
"body": "每次使用都要重新授权一次,说旧的已经过期,有什么方法可持久授权吗?",
"expected_type": null,
"expected_domains": [
"auth",
"core"
],
"source_url": "https://github.com/larksuite/cli/issues/187",
"source_labels": []
},
{
"name": "#167 什么时候支持私有化版本飞书呢?",
"title": "什么时候支持私有化版本飞书呢?",
"body": "什么时候支持私有化版本飞书呢?",
"expected_type": null,
"expected_domains": [
"core"
],
"source_url": "https://github.com/larksuite/cli/issues/167",
"source_labels": []
},
{
"name": "#184 [Bug] docs +update --mode replace_all 导致文档内容被重复追加(100% 复现)",
"title": "[Bug] docs +update --mode replace_all 导致文档内容被重复追加(100% 复现)",
"body": "# 问题描述(由 Claude Code 发现并总结)\n\n## 环境信息\n\n- **lark-cli 版本**: 1.0.1\n- **操作系统**: macOS (Darwin 25.2.0, arm64)\n- **Node.js**: v25.2.1\n- **身份**: user 模式\n\n## 问题描述\n\n使用 `docs +update --mode replace_all` 对飞书云文档执行纯文本关键词替换时,文档内容不是被原地替换,而是**整个文档内容被复制一份追加到末尾**。每执行一次,文档就多一份副本。\n\n## 复现步骤\n\n1. 准备一份飞书云文档(wiki 类型,obj_type=docx),文档包含嵌入电子表格(`<sheet token=\"...\"/>`)、图片(`<image token=\"...\"/>`)、文档引用(`<mention-doc token=\"...\"/>`)等 PROTECTED 内容,总计约 12 个 token,文档大小约 38,000 字符。\n\n2. 执行以下命令:\n```bash\nlark-cli docs +update \\\n --doc \"<doc_token>\" \\\n --mode replace_all \\\n --selection-with-ellipsis \"oldKeyword\" \\\n --markdown \"newKeyword\"\n```\n\n3. 命令返回异步 task_id\n```json\n{\n \"ok\": true,\n \"data\": {\n \"status\": \"running\",\n \"task_id\": \"<task_id>\",\n \"tool\": \"update_doc\"\n }\n}\n```\n\n4. 等待 10 秒后 fetch 文档内容验证。\n\n## 预期行为\n\n文档中 3 处 `oldKeyword` 被替换为 `newKeyword`,文档大小基本不变。\n\n## 实际行为\n\n- 文档大小从 **~38,000 字符膨胀到 ~77,000 字符**(精确翻倍)\n- 文档一级标题从出现 1 次变为 2 次(整个文档被复制了一份追加到末尾)\n- PROTECTED token 数量从 12 个变为 22 个(新副本中生成了新的 token\n- 原始内容中 `oldKeyword` 仍存在 3 处(在未被替换的副本中)\n- `newKeyword` 从 3 处变为 9 处\n\n**在未恢复的情况下再次执行同一命令,文档进一步膨胀到 ~115,000 字符(3 份副本),标题出现 3 次。**\n\n## 复现率\n\n**3/3 次,100% 复现。**\n\n| 次数 | 操作前 | 操作后 | 结果 |\n|------|--------|--------|------|\n\n...(truncated)",
"expected_type": "bug",
"expected_domains": [
"doc",
"sheets"
],
"source_url": "https://github.com/larksuite/cli/issues/184",
"source_labels": []
},
{
"name": "#185 lark-cli 不支持多个 Gateway 实例共享配置",
"title": "lark-cli 不支持多个 Gateway 实例共享配置",
"body": "## 问题描述(由 Claude Code 发现并总结)\n\nlark-cli 使用全局配置文件 `~/.lark-cli/config.json`,不支持多实例共享或指定配置文件路径。\n\n当有多个 OpenClaw Gateway 实例(如 Friday、Andy、vovo)需要各自使用不同的飞书 App 时,lark-cli 只能配置一个 App,导致其他实例的图片/文件发送会路由到错误的 App。\n\n## 复现场景\n\n1. 部署多个 OpenClaw Gateway 实例\n2. 每个实例使用不同的飞书 App(不同的 App ID/Secret\n3. 尝试使用 lark-cli 发送图片\n\n## 当前问题\n\n- lark-cli 只读取 `~/.lark-cli/config.json`\n- 没有 `--config` 或环境变量方式指定其他配置文件\n- 导致不同 Gateway 实例混用同一个 App 认证\n\n## 期望行为\n\n支持以下任一方案:\n\n1. **多配置文件支持**:通过 `--config <path>` 指定配置文件路径\n2. **环境变量配置**`LARK_CONFIG_PATH` 环境变量\n3. **全局配置节**:在单一配置文件中支持多个 App 配置,通过 `--app-id` 切换\n\n## 替代方案(临时解决)\n\n为每个 Gateway 创建独立的发送脚本,直接调用飞书 API 而非使用 lark-cli\n\n```bash\n#!/bin/bash\n# feishu-send-image.sh - 使用指定 App 发送图片\nAPP_ID=\"cli_xxxxx\"\nAPP_SECRET=\"xxxxx\"\n# 直接调用飞书 API\n```\n\n但这增加了维护成本,且无法使用 lark-cli 的其他功能。\n\n## 环境信息\n\n- OS: macOS\n- lark-cli 版本: 1.0.0+\n- Node.js: 22.x",
"expected_type": "bug",
"expected_domains": [
"core"
],
"source_url": "https://github.com/larksuite/cli/issues/185",
"source_labels": [
"bug",
"domain/event"
]
},
{
"name": "#177 通过auth login仅选择已有的Scope进行登录,但依然会跳转到Scope Authorization",
"title": "通过auth login仅选择已有的Scope进行登录,但依然会跳转到Scope Authorization",
"body": "我登录Scopes仅选择docs, 这是我已有的权限,但链接依然会跳转到Scope Authorization去申请全部权限",
"expected_type": null,
"expected_domains": [
"auth"
],
"source_url": "https://github.com/larksuite/cli/issues/177",
"source_labels": []
},
{
"name": "#182 只能创建一个应用,config配置内使用apps:[],看着像支持多个应用,实际上每次都是覆盖,没法同时配置多个",
"title": "只能创建一个应用,config配置内使用apps:[],看着像支持多个应用,实际上每次都是覆盖,没法同时配置多个",
"body": "",
"expected_type": null,
"expected_domains": [
"core"
],
"source_url": "https://github.com/larksuite/cli/issues/182",
"source_labels": [
"enhancement"
]
},
{
"name": "#248 [Bug] macOS: `config init --new` creates encrypted files but does not persist `master.key` to Keychain, causing fresh processes to fail",
"title": "[Bug] macOS: `config init --new` creates encrypted files but does not persist `master.key` to Keychain, causing fresh processes to fail",
"expected_type": "bug",
"expected_domains": [
"auth",
"core"
],
"source_url": "https://github.com/larksuite/cli/issues/248",
"source_labels": []
},
{
"name": "#153 feat(drive): add folder/file management shortcuts for Drive API",
"title": "feat(drive): add folder/file management shortcuts for Drive API",
"body": "## Feature Request\n\nAdd Drive file/folder management shortcuts to lark-cli.\n\n## Current Gap\n\nThe current Drive module only supports 3 commands:\n- `+upload` - Upload a local file\n- `+download` - Download a file\n- `+add-comment` - Add a comment to a file\n\nMissing essential Drive operations for building a complete CLI experience:\n- Create folders/directories\n- List files in a folder\n- Copy/Move files\n- Delete files\n- Get file metadata (including folder token)\n\n## Use Case\n\nFor AI Agent (Hermes) integration, I want to build an automated knowledge base system:\n1. Create a folder hierarchy for knowledge management (e.g., 🧠 Knowledge Base > Embedded/AI/DevOps)\n2. Programmatically organize files into folders\n3. Link folder tokens to Bitable index records\n\nCurrently `lark-cli drive` is insufficient for this use case.\n\n## Proposed Shortcuts\n\n```go\nfunc Shortcuts() []common.Shortcut {\n return []common.Shortcut{\n DriveUpload,\n DriveDownload,\n DriveAddComment,\n // New:\n DriveCreateFolder, // lark-cli drive +create-folder\n DriveListFiles, // lark-cli drive +list\n DriveFileCopy, // lark-cli drive +copy (extends existing copy)\n DriveFileDelete, // lark-cli drive +delete\n DriveGetFileMeta, // lark-cli drive +meta\n }\n}\n```\n\n## Workaround\n\nCurrently I'm using lark-cli api to create folders, but the drive API needs a user identity scope which lark-cli does not have built-in support for.\n\n## Context\n\nThis issue arises when integrating lark-cli with [Hermes Agent](https://github.com/openmule/hermes-agent) for knowledge base creation in Feishu.\n",
"expected_type": "enhancement",
"expected_domains": [
"drive"
],
"source_url": "https://github.com/larksuite/cli/issues/153",
"source_labels": []
},
{
"name": "#143 Feature Request: Add base +record-search command (POST /records/search API)",
"title": "Feature Request: Add base +record-search command (POST /records/search API)",
"body": "## Background\n\nCurrently, querying records in a Bitable table requires `+record-list` with client-side filtering. For large tables (2400+ records), this requires 13+ serial API calls and takes 60+ seconds.\n\nThe Feishu Open Platform provides a more efficient `/records/search` API:\n- **Endpoint**: `POST /open-apis/bitable/v1/apps/:app_token/tables/:table_id/records/search`\n- **Server-side filtering**: supports `contains`, `is`, `isNot`, etc. on any field\n- **Larger page size**: up to 500 records per request (vs 200 for list)\n- **Result**: 12 requests instead of 13+, roughly 10100x faster\n\n## Problem with current `lark-cli api`\n\nCalling this endpoint via `lark-cli api` silently exits with code 1, no stdout, no stderr:\n\n```bash\nMSYS_NO_PATHCONV=1 lark-cli api POST \"/open-apis/bitable/v1/apps/xxx/tables/yyy/records/search\" --as user --data '{\"page_size\":5}'\n# Exit: 1, no output at all\n```\n\nGET requests to other bitable endpoints work fine via `lark-cli api`. The issue appears specific to POST `/records/search`.\n\n## Requested Command\n\n```bash\nlark-cli base +record-search --base-token <token> --table-id <table_id> --filter '{\"conjunction\":\"and\",\"conditions\":[{\"field_name\":\"会议名称\",\"operator\":\"contains\",\"value\":[\"keyword\"]}]}' --sort '[{\"field_name\":\"会议开始时间\",\"desc\":true}]' --field-names '[\"会议名称\",\"会议开始时间\",\"面试录音\"]' --page-size 500 --page-all\n```\n\n## Performance Impact\n\nSearching interview records in a 2400-row Bitable table:\n\n| Method | Requests | Time |\n|--------|----------|------|\n| Current `+record-list` (full scan) | 13 serial calls | 60+ sec |\n| Optimized `+record-list` (range scan) | 5 calls | ~6 sec |\n| `+record-search` (server-side filter) | 12 calls | ~1 sec |\n\n## Reference\n\n- [Search records API](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/bitable-v1/app-table-record/search)",
"expected_type": "enhancement",
"expected_domains": [
"base"
],
"source_url": "https://github.com/larksuite/cli/issues/143",
"source_labels": []
},
{
"name": "#165 macOS arm64: lark-cli binary fails with exit code 137 (SIGKILL)",
"title": "macOS arm64: lark-cli binary fails with exit code 137 (SIGKILL)",
"body": "# lark-cli Binary Fails to Execute on macOS arm64 (Exit Code 137/SIGKILL)\n\n## Environment\n\n- **OS**: macOS 15.1 (Darwin 25.1.0) arm64\n- **lark-cli version**: v1.0.1\n- **Node.js**: v22.21.1\n- **npm**: 10.9.4\n- **Installation method**: npm global install\n- **Architecture**: Apple Silicon (arm64)\n\n## Description\n\nAfter successfully installing `@larksuite/cli` via npm and running the postinstall script, the `lark-cli` binary fails to execute with exit code 137 (SIGKILL). The binary is killed by the system immediately upon execution without producing any output.\n\n## Steps to Reproduce\n\n1. Install lark-cli globally:\n```bash\nnpm install -g @larksuite/cli\n```\n\n2. Run postinstall script manually (as per issue #135):\n```bash\ncd ~/.nvm/versions/node/v22.21.1/lib/node_modules/@larksuite/cli\nnode scripts/install.js\n```\nOutput: `lark-cli v1.0.1 installed successfully`\n\n3. Verify binary exists and is executable:\n```bash\nls -la ~/.nvm/versions/node/v22.21.1/lib/node_modules/@larksuite/cli/bin/lark-cli\nfile ~/.nvm/versions/node/v22.21.1/lib/node_modules/@larksuite/cli/bin/lark-cli\n```\nOutput:\n```\n-rwxr-xr-x 1 yang staff 14846642 Mar 31 20:46 lark-cli\nMach-O 64-bit executable arm64\n```\n\n4. Attempt to run any lark-cli command:\n```bash\nlark-cli --version\nlark-cli --help\nlark-cli config init\n```\n\n## Expected Behavior\n\nThe command should display help text, version information, or start the configuration wizard.\n\n## Actual Behavior\n\n- The process exits immediately with code 137 (SIGKILL)\n- No output is produced on stdout or stderr\n- The process appears in `ps` briefly then disappears\n- No crash reports are generated in `~/Library/Logs/DiagnosticReports/`\n\n## Additional Information\n\n### Binary Details\n\n```bash\notool -L ~/.nvm/versions/node/v22.21.1/lib/node_modules/@larksuite/cli/bin/lark-cli\n```\n\nOutput:\n```\n/usr/lib/libSystem.B.dylib (compatibility version 0.0.0, current version 0.0.0)\n/usr/lib/libresolv.9.dylib (compatibility version 0.0.0, current version 0.0.0)\n/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation\n/System/Library/Frameworks/Security.framework/Versions/A/Security\n```\n\n### Attempted Solutions\n\n1. **Tried downloading pre-built binary from GitHub releases:**\n - Downloaded `lark-cli-1.0.1-darwin-arm64.tar.gz`\n - Same behavior (exit code 137)\n\n2. **Attempted source build:**\n - Encountered SSL certificate verification error during `make install`\n - Compiled binary exhibits same exit code 137 behavior\n\n3. **Checked for quarantine attributes:**\n - No `com.apple.quarantine` attribute found\n - `spctl --assess` rejects the binary\n\n4. **Skills installation successful:**\n - `npx skills add larksuite/cli -y -g` completed successfully\n - All 19 skills installed to `~/.agents/skills/`\n\n## Exit Code 137 Context\n\nExit code 137 = 128 + 9 = SIGKILL, which typically indicates:\n- The process was forcibly terminated by the system\n- Possible code signing issues\n- Possible compatibility issues with the macOS version\n- Security restrictions preventing execution\n\n## Related Issues\n\n- Issue #135: pnpm installation requires manual postinstall (similar installation flow issue)\n- Issue #70: macOS token persistence issues (macOS-specific behavior)\n- Issue #122: Silent failures on macOS arm64 environment\n\n## Workaround Needed\n\nIs there:\n1. A known issue with code signing for the macOS arm64 binary?\n2. An alternative installation method that works on macOS 15.x?\n3. Any additional configuration needed for the binary to execute?\n\n## System Details\n\n```bash\n# macOS Version\nsw_vers\n```\nOutput:\n```\nProductName:\t\tmacOS\nProductVersion:\t\t15.1\nBuildVersion:\t\t24B83\n```\n\n```bash\n# Architecture\nuname -m\n```\nOutput: `arm64`\n\n```bash\n# Go version (for reference)\ngo version\n```\nOutput: `go version go1.25.7 darwin/arm64`\n\n---\n\nThe skills package installed successfully, but the CLI binary itself cannot be used. Any guidance would be appreciated!",
"expected_type": "bug",
"expected_domains": [
"core"
],
"source_url": "https://github.com/larksuite/cli/issues/165",
"source_labels": []
},
{
"name": "#107 Request Windows binary release",
"title": "Request Windows binary release",
"body": "## Request: Add Windows (win32) binary release\\n\\n### Problem\\nThe current GitHub release (v1.0.0) only contains macOS and Linux binaries. The Windows platform is not supported.\\n\\nWhen installing via npm (\npm install -g @larksuite/cli) on Windows, the install script attempts to download lark-cli-1.0.0-windows-amd64.zip from the release page, but this file does not exist, resulting in a 404 error followed by connection timeout.\\n\\n### Evidence\\n- Release assets only contain: lark-cli-1.0.0-darwin-amd64.tar.gz, lark-cli-1.0.0-darwin-arm64.tar.gz, lark-cli-1.0.0-linux-amd64.tar.gz\\n- No lark-cli-1.0.0-windows-amd64.zip\\n\\n### Why this matters\\nWindows is a major platform for development and AI agent workflows. Without a pre-built binary, Windows users must build from source (requiring Go 1.23+), which is a significant barrier.\\n\\n### Request\\nPlease add Windows binary to the CI/CD release pipeline. The install script already supports win32 (see scripts/install.js platform mapping), so it should be straightforward to add a Windows build step.\\n\\n### Additional context\\n- Node.js version: v24.14.0 (Windows x64)\\n- Platform: Windows_NT 10.0.22631\\n",
"expected_type": "enhancement",
"expected_domains": [
"core"
],
"source_url": "https://github.com/larksuite/cli/issues/107",
"source_labels": []
},
{
"name": "#147 [Feature] Implement Encrypted File Fallback for Keychain Storage",
"title": "[Feature] Implement Encrypted File Fallback for Keychain Storage",
"body": "### **Problem Statement**\nCurrently, `lark-cli` relies on the system keychain on macOS to store sensitive information like App Secrets and User Access Tokens. However, in certain environments—such as restricted sandboxes, headless CI/CD servers, or remote SSH sessions —the system keychain may be unavailable.\n\nWhen this happens, the CLI currently fails to save configurations, preventing users from completing `lark-cli config init` or logging in.\n\n### **Proposed Solution**\nIntroduce a secondary, locally-managed encrypted storage layer. If the primary system keychain is unreachable or returns an \"unavailable\" error, the CLI should gracefully degrade to storing credentials in an AES-GCM encrypted file within the users config directory.\n\n### **Requirements**\n* **Encryption:** Use AES-GCM with a locally generated `master.key` (permission 0600) to ensure data at rest is not plain text.\n* **Transparency:** Warn the user when a fallback is being used so they are aware of the storage change.\n* **Safety:** Ensure that changing an App ID doesn't lead to the accidental reuse of a secret reference from a previous configuration.\n* **Refactoring:** Centralize config directory resolution and encryption logic to avoid duplication across platform-specific files.\n",
"expected_type": "enhancement",
"expected_domains": [
"auth",
"core"
],
"source_url": "https://github.com/larksuite/cli/issues/147",
"source_labels": []
},
{
"name": "#75 Feature Request: Support creating folders via drive command (+create-folder shortcut)",
"title": "Feature Request: Support creating folders via drive command (+create-folder shortcut)",
"body": "## Feature Request\n\n### Summary\nAdd a shortcut command to create folders in Lark Drive, similar to how `docs +create` creates documents.\n\n### Proposed Command\n```bash\nlark-cli drive +create-folder --folder-token <parent_folder_token> --name <folder_name>\n```\n\n### Underlying API\nThe Feishu Open Platform already provides this capability:\n- **Create Folder API:** `POST /open-apis/drive/v1/files/create_folder`\n- **Scope:** `drive:drive:write`\n- **API Doc:** https://open.feishu.cn/document/server-docs/docs/drive-v1/folder/create\n\n### Use Case\nWhen organizing files programmatically (e.g., creating weekly report folders under a parent directory), there is currently no way to create folders via `lark-cli`. The `drive` module only supports `files.copy`, not folder creation. The only workaround is using `lark-cli api POST /open-apis/drive/v1/files/create_folder --data '...'`, which is verbose and error-prone for a common operation.\n\n### Proposed Parameters\n| Flag | Required | Description |\n|------|----------|-------------|\n| `--folder-token` | Yes | Parent folder token where the new folder will be created |\n| `--name` | Yes | Name of the new folder |\n\n### Additional Context\nOther drive operations like `+upload`, `+download`, `+add-comment` already have shortcut commands. Folder creation is a fundamental operation and deserves the same treatment.",
"expected_type": "enhancement",
"expected_domains": [
"drive"
],
"source_url": "https://github.com/larksuite/cli/issues/75",
"source_labels": []
},
{
"name": "#82 Docs create bug: create --markdown only write the first line and discard the others",
"title": "Docs create bug: create --markdown only write the first line and discard the others",
"body": "<img width=\"946\" height=\"284\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/39b0c4ba-43a3-4210-acc3-889bb0f64ca4\" />\n\nwhen I test the docs cli, it only write the fist line of markdown content. my test demo is :\nlark-cli docs +create --title \"飞书CLI命令一览表-测试\" --markdown \"🔐 认证相关 命令 说明 lark-cli auth login 扫码登录(无需创建应用) lark-cli auth status 查看当前登录状态 lark-cli auth list 列出所有已登录用户 lark-cli auth logout 退出登录 ⚙️ 配置管理 命令 说明 lark-cli config init 初始化配置(App ID / App Secret lark-cli config show 显示当前配置 lark-cli config remove 移除配置 📅 日历 Calendar 命令 说明 lark-cli calendar +agenda 查看今日日程 lark-cli calendar +create 创建日历事件 lark-cli calendar events list 列出日历事件 👥 联系人 Contact 命令 说明 lark-cli contact +get-user 获取用户信息 lark-cli contact +search-user 搜索用户 💬 消息 IM 命令 说明 lark-cli im +chat-create 创建群聊 lark-cli im +chat-messages-list 查看聊天消息 lark-cli im +messages-reply 回复消息 lark-cli im +messages-send 发送消息 📝 文档 Docs 命令 说明 lark-cli docs +create 创建文档 lark-cli docs +fetch 获取文档内容 lark-cli docs +search 搜索文档 📊 表格 Sheets 命令 说明 lark-cli sheets +create 创建表格 lark-cli sheets +read 读取表格内容 lark-cli sheets +write 写入表格 lark-cli sheets +append 追加行 📁 云盘 Drive 命令 说明 lark-cli drive +upload 上传文件 lark-cli drive +download 下载文件 lark-cli drive +add-comment 添加评论 📧 邮件 Mail 命令 说明 lark-cli mail +draft-create 创建邮件草稿 lark-cli mail +message 查看邮件内容 ✅ 任务 Task 命令 说明 lark-cli task +create 创建任务 lark-cli task +complete 完成任务 lark-cli task +get-my-tasks 获取我的任务 🔧 通用功能 命令 说明 lark-cli api GET /path 调用任意 API lark-cli schema 查看 API 参数和权限 lark-cli doctor 健康检查\"",
"expected_type": "bug",
"expected_domains": [
"doc"
],
"source_url": "https://github.com/larksuite/cli/issues/82",
"source_labels": []
},
{
"name": "#254 你好",
"title": "你好",
"body": "",
"expected_type": null,
"expected_domains": [],
"source_url": "https://github.com/larksuite/cli/issues/254",
"source_labels": []
},
{
"name": "#9 Meeting room booking is possible, but room discovery is missing from CLI docs and command surface",
"title": "Meeting room booking is possible, but room discovery is missing from CLI docs and command surface",
"body": "Does it support meeting room discovery? Is there a `lark-cli calendar` command to discover meeting rooms? The CLI docs/command surface lacks room discovery.",
"expected_type": "question",
"expected_domains": [
"calendar",
"doc"
],
"source_url": "https://github.com/larksuite/cli/issues/9",
"source_labels": [
"enhancement"
]
},
{
"name": "#81 mail +draft-edit: add_inline does not actually insert inline image into HTML body",
"title": "mail +draft-edit: add_inline does not actually insert inline image into HTML body",
"body": "`add_inline` fails to insert inline image into HTML body. Expected the image to be present; actual: missing.",
"expected_type": "bug",
"expected_domains": [
"mail"
],
"source_url": "https://github.com/larksuite/cli/issues/81",
"source_labels": [
"enhancement",
"domain/mail"
]
},
{
"name": "#259 Feature: docs +fetch should support --resolve-media to auto-download images/files",
"title": "Feature: docs +fetch should support --resolve-media to auto-download images/files",
"body": "Feature request: `lark-cli docs +fetch` should support `--resolve-media` to download images/files referenced in docs.",
"expected_type": "enhancement",
"expected_domains": [
"doc"
],
"source_url": "https://github.com/larksuite/cli/issues/259",
"source_labels": [
"bug",
"domain/doc",
"domain/core"
]
},
{
"name": "#265 wiki只能获取到标题,无法获取到内容,针对mindmap",
"title": "wiki只能获取到标题,无法获取到内容,针对mindmap",
"body": "",
"expected_type": "bug",
"expected_domains": [
"wiki"
],
"source_url": "https://github.com/larksuite/cli/issues/265",
"source_labels": [
"enhancement",
"domain/wiki"
]
}
]
+73
View File
@@ -0,0 +1,73 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("fs");
const path = require("path");
const { classifyIssueText } = require("./index.js");
const samplesPath = path.join(__dirname, "samples.json");
const samples = JSON.parse(fs.readFileSync(samplesPath, "utf8"));
/**
* Convert an array-like value into a sorted string array.
*
* @param {Array<unknown>|undefined|null} arr
* @returns {string[]}
*/
function sortArray(arr) {
return (arr || []).map(String).sort();
}
/**
* Check whether every element in sub exists in sup.
*
* @param {string[]} sub
* @param {string[]} sup
* @returns {boolean}
*/
function isSubset(sub, sup) {
const set = new Set(sup || []);
for (const x of sub || []) {
if (!set.has(x)) return false;
}
return true;
}
let passed = 0;
let failed = 0;
for (const sample of samples) {
try {
const result = classifyIssueText(sample.title, sample.body);
const hasExpectedType = Object.prototype.hasOwnProperty.call(sample, "expected_type");
const expectedType = hasExpectedType ? sample.expected_type : undefined;
const matchType = hasExpectedType ? (result.type || null) === expectedType : true;
const actualDomains = sortArray(result.domains);
const expectedDomains = sortArray(sample.expected_domains);
const hasExpectedDomains = Object.prototype.hasOwnProperty.call(sample, "expected_domains");
const matchDomains = !hasExpectedDomains
? true
: expectedDomains.length === 0
? actualDomains.length === 0
: isSubset(expectedDomains, actualDomains);
if (matchType && matchDomains) {
console.log(`✅ Passed: ${sample.name}`);
passed += 1;
} else {
console.log(`❌ Failed: ${sample.name}`);
console.log(` Type expected: ${expectedType}, got: ${result.type}`);
console.log(` Domains expected(subset): ${expectedDomains}, got: ${actualDomains}`);
failed += 1;
}
} catch (e) {
console.log(`❌ Failed: ${sample.name} (Execution error)`);
console.error(e && e.message ? e.message : String(e));
failed += 1;
}
}
console.log(`\nTest Summary: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
+58
View File
@@ -0,0 +1,58 @@
# PR Label Sync
This directory contains scripts and sample data for automatically classifying and labeling GitHub Pull Requests based on the files they modify.
## Files
- `index.js`: The main Node.js script. It fetches PR files, evaluates their risk level, calculates business impact, and uses GitHub APIs to add appropriate `size/*` and `domain/*` labels.
- `samples.json`: A collection of historical PRs used as test cases to verify the labeling logic (especially for regression testing the S/M/L thresholds).
## Features
### Size Labels (`size/*`)
The script evaluates the "effective" lines of code changed (ignoring tests, docs, and ci files) to classify the PR:
- **`size/S`**: Low-risk changes involving only docs, tests, CI workflows, or chores.
- **`size/M`**: Small-to-medium changes affecting a single business domain, with effective lines under 300.
- **`size/L`**: Large features (>= 300 lines), cross-domain changes, or any changes touching core architecture paths (like `cmd/`).
- **`size/XL`**: Architectural overhauls, extremely large PRs (>1200 lines), or sensitive refactors.
### Domain Tags (`domain/*`)
The script also identifies which business domains a PR touches to give reviewers an immediate sense of the impact scope. Currently tracked domains include:
- `domain/im`
- `domain/vc`
- `domain/ccm`
- `domain/base`
- `domain/mail`
- `domain/calendar`
- `domain/task`
- `domain/contact`
Minor modules like docs and tests are omitted to keep PR tags clean and focused on structural changes.
## Usage
### In GitHub Actions
This script is designed to run in CI workflows. It automatically reads the `GITHUB_EVENT_PATH` payload to get the PR context.
```bash
node scripts/pr-labels/index.js
```
### Local Dry Run
You can test the labeling logic against an existing GitHub PR without actually applying labels by using the `--dry-run` flag.
```bash
# Requires GITHUB_TOKEN environment variable or passing --token
node scripts/pr-labels/index.js --dry-run --repo larksuite/cli --pr-number 123
```
## Testing
A regression test suite is available in `test.js` which verifies the output of the classification logic against historical PRs configured in `samples.json`.
```bash
# Requires GITHUB_TOKEN environment variable to avoid rate limits
GITHUB_TOKEN=$(gh auth token) node scripts/pr-labels/test.js
```
This test suite also runs automatically in CI via `.github/workflows/pr-labels-test.yml` when changes are made to this directory.
+717
View File
@@ -0,0 +1,717 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs/promises");
const path = require("node:path");
const { labelDomainsForPath } = require("../domain-map");
// ============================================================================
// Constants & Configuration
// ============================================================================
const API_BASE = "https://api.github.com";
const SCRIPT_DIR = __dirname;
const ROOT = path.join(SCRIPT_DIR, "..", "..");
const THRESHOLD_L = 300;
const THRESHOLD_XL = 1200;
const LABEL_DEFINITIONS = {
"size/S": { color: "77bb00", description: "Low-risk docs, CI, test, or chore only changes" },
"size/M": { color: "eebb00", description: "Single-domain feat or fix with limited business impact" },
"size/L": { color: "ff8800", description: "Large or sensitive change across domains or core paths" },
"size/XL": { color: "ee0000", description: "Architecture-level or global-impact change" },
};
const MANAGED_LABELS = new Set(Object.keys(LABEL_DEFINITIONS));
// File path matching configurations
const DOC_SUFFIXES = [".md", ".mdx", ".txt", ".rst"];
const LOW_RISK_PREFIXES = [".github/", "docs/", ".changeset/", "testdata/", "tests/", "skill-template/"];
const LOW_RISK_FILENAMES = new Set(["readme.md", "readme.zh.md", "changelog.md", "license", "cla.md"]);
const LOW_RISK_TEST_SUFFIXES = ["_test.go", ".snap"];
const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/", "cmd/"];
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
const CLASS_STANDARDS = {
"size/S": {
channel: "Fast track (S)",
gates: [
"Code quality: AI code review passed",
"Dependency and configuration security checks passed",
],
},
"size/M": {
channel: "Fast track (M)",
gates: [
"Code quality: AI code review passed",
"Dependency and configuration security checks passed",
"Skill format validation: added or modified Skills load successfully",
"CLI automation tests: all required business-line tests passed",
],
},
"size/L": {
channel: "Standard track (L)",
gates: [
"Code quality: AI code review passed",
"Dependency and configuration security checks passed",
"Skill format validation: added or modified Skills load successfully",
"CLI automation tests: all required business-line tests passed",
"Domain evaluation passed: reported success rate is greater than 95%",
],
},
"size/XL": {
channel: "Strict track (XL)",
gates: [
"Code quality: AI code review passed",
"Dependency and configuration security checks passed",
"Skill format validation: added or modified Skills load successfully",
"CLI automation tests: all required business-line tests passed",
"Domain evaluation passed: reported success rate is greater than 95%",
"Cross-domain release gate: all domains and full integration evaluations passed",
],
},
};
// ============================================================================
// Utilities
// ============================================================================
function log(message) {
console.error(`sync-pr-labels: ${message}`);
}
function normalizePath(input) {
return String(input || "").trim().toLowerCase();
}
function envValue(name) {
return (process.env[name] || "").trim();
}
function envOrFail(name) {
const value = envValue(name);
if (!value) {
throw new Error(`missing required environment variable: ${name}`);
}
return value;
}
// ============================================================================
// GitHub API Client
// ============================================================================
class GitHubClient {
constructor(token, repo, prNumber) {
this.token = token;
this.repo = repo;
this.prNumber = prNumber;
}
buildHeaders(hasBody = false) {
const headers = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
};
if (this.token) {
headers.Authorization = `Bearer ${this.token}`;
}
if (hasBody) {
headers["Content-Type"] = "application/json";
}
return headers;
}
async request(endpoint, options = {}) {
const { method = "GET", payload, allow404 = false } = options;
const hasBody = payload !== undefined;
const url = endpoint.startsWith("http") ? endpoint : `${API_BASE}${endpoint}`;
const response = await fetch(url, {
method,
headers: this.buildHeaders(hasBody),
body: hasBody ? JSON.stringify(payload) : undefined,
});
if (allow404 && response.status === 404) {
return null;
}
if (!response.ok) {
const detail = await response.text();
const error = new Error(`GitHub API ${method} ${url} failed: ${response.status} ${detail}`);
error.status = response.status;
throw error;
}
const text = await response.text();
return text ? JSON.parse(text) : null;
}
async getPullRequest() {
return this.request(`/repos/${this.repo}/pulls/${this.prNumber}`);
}
async listPrFiles() {
const files = [];
for (let page = 1; ; page += 1) {
const params = new URLSearchParams({ per_page: "100", page: String(page) });
const batch = await this.request(`/repos/${this.repo}/pulls/${this.prNumber}/files?${params}`);
if (!batch || batch.length === 0) {
break;
}
files.push(...batch);
if (batch.length < 100) {
break;
}
}
return files;
}
async listIssueLabels() {
const labels = await this.request(`/repos/${this.repo}/issues/${this.prNumber}/labels`);
return new Set(labels.map((item) => item.name));
}
async syncLabelDefinition(name) {
const label = LABEL_DEFINITIONS[name];
const createUrl = `/repos/${this.repo}/labels`;
const updateUrl = `/repos/${this.repo}/labels/${encodeURIComponent(name)}`;
try {
await this.request(createUrl, {
method: "POST",
payload: { name, color: label.color, description: label.description },
});
log(`created label ${name}`);
} catch (error) {
if (error.status !== 422) {
throw error;
}
await this.request(updateUrl, {
method: "PATCH",
payload: { new_name: name, color: label.color, description: label.description },
});
log(`updated label ${name}`);
}
}
async addLabels(labels) {
if (labels.length === 0) return;
await this.request(`/repos/${this.repo}/issues/${this.prNumber}/labels`, {
method: "POST",
payload: { labels },
});
log(`added labels: ${labels.join(", ")}`);
}
async removeLabel(name) {
await this.request(`/repos/${this.repo}/issues/${this.prNumber}/labels/${encodeURIComponent(name)}`, {
method: "DELETE",
allow404: true,
});
log(`removed label: ${name}`);
}
}
// ============================================================================
// Path & Domain Heuristics
// ============================================================================
function parsePrType(title) {
const match = String(title || "").trim().match(/^([a-z]+)(?:\([^)]+\))?!?:/i);
return match ? match[1].toLowerCase() : "";
}
function isLowRiskPath(filePath) {
const normalized = normalizePath(filePath);
const basename = path.posix.basename(normalized);
if (normalized.startsWith("skills/lark-")) return false;
if (DOC_SUFFIXES.some((suffix) => normalized.endsWith(suffix))) return true;
if (LOW_RISK_FILENAMES.has(basename)) return true;
if (LOW_RISK_PREFIXES.some((prefix) => normalized.startsWith(prefix))) return true;
if (LOW_RISK_TEST_SUFFIXES.some((suffix) => normalized.endsWith(suffix))) return true;
return normalized.includes("/testdata/");
}
function isBusinessSkillPath(filePath) {
const normalized = normalizePath(filePath);
return normalized.startsWith("shortcuts/") || normalized.startsWith("skills/lark-");
}
function shortcutDomainForPath(filePath) {
const parts = normalizePath(filePath).split("/");
return parts.length >= 2 && parts[0] === "shortcuts" ? parts[1] : "";
}
function skillDomainForPath(filePath) {
const parts = normalizePath(filePath).split("/");
return parts.length >= 2 && parts[0] === "skills" && parts[1].startsWith("lark-")
? parts[1].slice("lark-".length)
: "";
}
// Get business domain label based on CODEOWNERS path mapping
function getBusinessDomain(filePath) {
return labelDomainsForPath(filePath)[0] || "";
}
async function detectNewShortcutDomain(files) {
for (const item of files) {
if (item.status !== "added") continue;
const domain = shortcutDomainForPath(item.filename);
if (!domain) continue;
try {
await fs.access(path.join(ROOT, "shortcuts", domain));
} catch {
return domain;
}
}
return "";
}
function collectCoreAreas(filenames) {
const areas = new Set();
for (const name of filenames) {
const normalized = normalizePath(name);
for (const prefix of CORE_PREFIXES) {
if (normalized.startsWith(prefix)) {
// remove trailing slash for area name
areas.add(prefix.slice(0, -1));
}
}
}
return areas;
}
function collectSensitiveKeywords(filenames) {
const hits = new Set();
for (const name of filenames) {
const match = normalizePath(name).match(SENSITIVE_PATTERN);
if (match && match[2]) {
hits.add(match[2]);
}
}
return [...hits].sort();
}
// ============================================================================
// Classification Logic
// ============================================================================
function evaluateRules(context) {
const {
prType, effectiveChanges, lowRiskOnly,
domains, headDomains, coreAreas, coreSignals,
sensitiveKeywords, sensitive, newShortcutDomain,
singleDomain, multiDomain, filenames
} = context;
const reasons = [];
let label;
if (lowRiskOnly && (LOW_RISK_TYPES.has(prType) || effectiveChanges === 0)) {
reasons.push("Only low-risk docs, CI, test, or chore paths were changed, with no effective business code or Skill changes");
label = "size/S";
return { label, reasons };
}
// XL is reserved for architecture-level or global-impact changes.
const isXL =
effectiveChanges > THRESHOLD_XL ||
(prType === "refactor" && sensitive && effectiveChanges >= THRESHOLD_L) ||
(coreAreas.size >= 2 && (multiDomain || effectiveChanges >= THRESHOLD_L)) ||
(headDomains.length >= 2 && sensitive);
if (isXL) {
if (effectiveChanges > THRESHOLD_XL) reasons.push("Effective business code or Skill changes are far beyond the L threshold");
if (prType === "refactor" && sensitive && effectiveChanges >= THRESHOLD_L) reasons.push("Refactor PR touches core or sensitive paths");
if (coreAreas.size >= 2) reasons.push("Touches multiple core areas at the same time");
if (headDomains.length >= 2) reasons.push("Impacts multiple major business domains");
coreSignals.forEach((signal) => reasons.push(`Core area hit: ${signal}`));
sensitiveKeywords.forEach((keyword) => reasons.push(`Sensitive keyword hit: ${keyword}`));
label = "size/XL";
} else if (
prType === "refactor" ||
effectiveChanges >= THRESHOLD_L ||
Boolean(newShortcutDomain) ||
multiDomain ||
sensitive
) {
if (prType === "refactor") reasons.push("PR type is refactor");
if (effectiveChanges >= THRESHOLD_L) reasons.push(`Effective business code or Skill changes exceed ${THRESHOLD_L} lines`);
if (newShortcutDomain) reasons.push(`Introduces a new business domain directory: shortcuts/${newShortcutDomain}/`);
if (multiDomain) reasons.push("Touches multiple business domains");
coreSignals.forEach((signal) => reasons.push(`Core area hit: ${signal}`));
sensitiveKeywords.forEach((keyword) => reasons.push(`Sensitive keyword hit: ${keyword}`));
label = "size/L";
} else {
if (filenames.some(isBusinessSkillPath) || effectiveChanges > 0) {
reasons.push("Regular feat, fix, or Skill change within a single business domain");
}
if (singleDomain && domains.size > 0) {
reasons.push(`Impact is limited to a single business domain: ${[...domains].sort().join(", ")}`);
}
if (effectiveChanges < THRESHOLD_L) {
reasons.push(`Effective business code or Skill changes are below ${THRESHOLD_L} lines`);
}
label = "size/M";
}
return { label, reasons };
}
async function classifyPr(payload, files) {
const pr = payload.pull_request;
const title = pr.title || "";
const prType = parsePrType(title);
const filenames = files.map((item) => item.filename || "");
const impactedPaths = files.flatMap((item) => {
const paths = [item.filename || ""];
if (item.status === "renamed" && item.previous_filename) {
paths.push(item.previous_filename);
}
return paths.filter(Boolean);
});
// Filter out docs, tests, and other low-risk paths so the size label tracks business impact.
const effectiveChanges = files.reduce(
(sum, item) => sum + (isLowRiskPath(item.filename) ? 0 : (item.changes || 0)),
0,
);
const totalChanges = files.reduce((sum, item) => sum + (item.changes || 0), 0);
const domains = new Set();
const businessDomains = new Set();
for (const name of impactedPaths) {
const businessDomain = getBusinessDomain(name);
if (businessDomain) {
businessDomains.add(businessDomain);
domains.add(businessDomain);
continue;
}
const shortcutDomain = shortcutDomainForPath(name);
if (shortcutDomain) domains.add(shortcutDomain);
const skillDomain = skillDomainForPath(name);
if (skillDomain) domains.add(skillDomain);
}
const coreAreas = collectCoreAreas(impactedPaths);
const newShortcutDomain = await detectNewShortcutDomain(files);
const lowRiskOnly = impactedPaths.length > 0 && impactedPaths.every(isLowRiskPath);
const singleDomain = domains.size <= 1;
const multiDomain = domains.size >= 2;
const headDomains = [...domains].filter((domain) => HEAD_BUSINESS_DOMAINS.has(domain));
const coreSignals = [...coreAreas].sort();
const sensitiveKeywords = collectSensitiveKeywords(impactedPaths);
const sensitive = coreSignals.length > 0 || sensitiveKeywords.length > 0;
const context = {
prType, effectiveChanges, lowRiskOnly,
domains, headDomains, coreAreas, coreSignals,
sensitiveKeywords, sensitive, newShortcutDomain,
singleDomain, multiDomain, filenames: impactedPaths
};
const { label, reasons } = evaluateRules(context);
return {
label,
title,
prType: prType || "unknown",
totalChanges,
effectiveChanges,
domains: [...domains].sort(),
businessDomains: [...businessDomains].sort(),
coreAreas: [...coreAreas].sort(),
coreSignals,
sensitiveKeywords,
newShortcutDomain,
reasons,
lowRiskOnly,
filenames,
};
}
// ============================================================================
// Output & Formatting
// ============================================================================
async function writeStepSummary(prNumber, classification) {
const summaryPath = (process.env.GITHUB_STEP_SUMMARY || "").trim();
if (!summaryPath) return;
const standard = CLASS_STANDARDS[classification.label];
const domains = classification.domains.join(", ") || "-";
const bDomains = classification.businessDomains.join(", ") || "-";
const coreAreas = classification.coreAreas.join(", ") || "-";
const reasons = classification.reasons.length > 0
? classification.reasons
: ["No higher-severity rule matched, so the PR defaults to medium classification"];
const lines = [
"## PR Size Classification",
"",
`- PR: #${prNumber}`,
`- Label: \`${classification.label}\``,
`- PR Type: \`${classification.prType}\``,
`- Total Changes: \`${classification.totalChanges}\``,
`- Effective Business/SKILL Changes: \`${classification.effectiveChanges}\``,
`- Business Domains: \`${domains}\``,
`- Impacted Domains: \`${bDomains}\``,
`- Core Areas: \`${coreAreas}\``,
`- CI/CD Channel: \`${standard.channel}\``,
`- Low Risk Only: \`${classification.lowRiskOnly}\``,
"",
"### Reasons",
"",
...reasons.map((reason) => `- ${reason}`),
"",
"### Pipeline Gates",
"",
...standard.gates.map((gate) => `- ${gate}`),
"",
];
await fs.appendFile(summaryPath, `${lines.join("\n")}\n`, "utf8");
}
function formatDryRunResult(repo, prNumber, classification) {
const standard = CLASS_STANDARDS[classification.label];
return {
repo,
prNumber,
label: classification.label,
prType: classification.prType,
totalChanges: classification.totalChanges,
effectiveChanges: classification.effectiveChanges,
lowRiskOnly: classification.lowRiskOnly,
domains: classification.domains,
businessDomains: classification.businessDomains,
coreAreas: classification.coreAreas,
coreSignals: classification.coreSignals,
sensitiveKeywords: classification.sensitiveKeywords,
reasons: classification.reasons,
channel: standard.channel,
gates: standard.gates,
};
}
function printDryRunResult(result, options) {
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
const signalParts = [
...result.coreSignals.map((signal) => `core:${signal}`),
...result.sensitiveKeywords.map((keyword) => `keyword:${keyword}`),
...(result.domains.length > 0 ? [`domains:${result.domains.join(",")}`] : []),
];
const reasonParts = result.reasons.length > 0
? result.reasons
: ["No higher-severity rule matched, so the PR defaults to medium classification"];
console.log(
`${result.label} | #${result.prNumber} | type:${result.prType} | eff:${result.effectiveChanges} | `
+ `sig:${signalParts.join(";") || "-"} | reason:${reasonParts.join("; ")}`,
);
}
function printHelp() {
const lines = [
"Usage:",
" node scripts/pr-labels/index.js",
" node scripts/pr-labels/index.js --dry-run --pr-url <github-pr-url> [--token <token>] [--json]",
" node scripts/pr-labels/index.js --dry-run --repo <owner/name> --pr-number <number> [--token <token>] [--json]",
"",
"Modes:",
" default Read the GitHub Actions event payload and apply labels",
" --dry-run Fetch the PR, compute the managed label, and print the result without writing labels",
"",
"Options:",
" --pr-url <url> GitHub pull request URL, for example https://github.com/larksuite/cli/pull/123",
" --repo <owner/name> Repository name, used with --pr-number",
" --pr-number <n> Pull request number, used with --repo",
" --token <token> GitHub token override; falls back to GITHUB_TOKEN",
" --json Print dry-run output as JSON instead of the default one-line summary",
" --help Show this message",
];
console.log(lines.join("\n"));
}
function parseArgs(argv) {
const options = {
dryRun: false,
json: false,
help: false,
prUrl: "",
repo: "",
prNumber: "",
token: "",
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--dry-run") options.dryRun = true;
else if (arg === "--json") options.json = true;
else if (arg === "--help" || arg === "-h") options.help = true;
else if (arg === "--pr-url") options.prUrl = argv[++i] || "";
else if (arg === "--repo") options.repo = argv[++i] || "";
else if (arg === "--pr-number") options.prNumber = argv[++i] || "";
else if (arg === "--token") options.token = argv[++i] || "";
else throw new Error(`unknown argument: ${arg}`);
}
return options;
}
function parsePrUrl(prUrl) {
let parsed;
try {
parsed = new URL(prUrl);
} catch {
throw new Error(`invalid PR URL: ${prUrl}`);
}
const match = parsed.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/);
if (!match) throw new Error(`unsupported PR URL format: ${prUrl}`);
return { repo: `${match[1]}/${match[2]}`, prNumber: Number(match[3]) };
}
async function loadEventPayload(filePath) {
return JSON.parse(await fs.readFile(filePath, "utf8"));
}
async function resolveContext(options) {
const token = options.token;
if (options.prUrl) {
const { repo, prNumber } = parsePrUrl(options.prUrl);
const client = new GitHubClient(token, repo, prNumber);
const payload = {
repository: { full_name: repo },
pull_request: await client.getPullRequest(),
};
return { repo, prNumber, payload, client };
}
if (options.repo || options.prNumber) {
if (!options.repo || !options.prNumber) throw new Error("--repo and --pr-number must be provided together");
const prNumber = Number(options.prNumber);
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error(`invalid PR number: ${options.prNumber}`);
const client = new GitHubClient(token, options.repo, prNumber);
const payload = {
repository: { full_name: options.repo },
pull_request: await client.getPullRequest(),
};
return { repo: options.repo, prNumber, payload, client };
}
const eventPath = envOrFail("GITHUB_EVENT_PATH");
const payload = await loadEventPayload(eventPath);
const repo = payload.repository.full_name;
const prNumber = payload.pull_request.number;
const client = new GitHubClient(token, repo, prNumber);
return { repo, prNumber, payload, client };
}
// ============================================================================
// Main Execution
// ============================================================================
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
return;
}
options.token = options.token || envValue("GITHUB_TOKEN");
if (!options.dryRun && !options.token) {
throw new Error("missing required GitHub token; set GITHUB_TOKEN or pass --token");
}
const { repo, prNumber, payload, client } = await resolveContext(options);
const files = await client.listPrFiles();
const classification = await classifyPr(payload, files);
if (options.dryRun) {
printDryRunResult(formatDryRunResult(repo, prNumber, classification), options);
return;
}
const desired = new Set([classification.label]);
for (const domain of classification.businessDomains) {
desired.add(`domain/${domain}`);
}
const current = await client.listIssueLabels();
const managedCurrent = [...current].filter((label) => MANAGED_LABELS.has(label) || label.startsWith("domain/"));
const toAdd = [...desired].filter((label) => !current.has(label)).sort();
const toRemove = managedCurrent.filter((label) => !desired.has(label)).sort();
for (const domain of classification.businessDomains) {
const labelName = `domain/${domain}`;
if (!LABEL_DEFINITIONS[labelName]) {
LABEL_DEFINITIONS[labelName] = { color: "1d76db", description: `PR touches the ${domain} domain` };
}
}
// Ensure labels to be added actually exist in the repository first
// If the label doesn't exist, GitHub API will return 422 Unprocessable Entity when trying to add it to a PR.
for (const label of toAdd) {
if (LABEL_DEFINITIONS[label]) {
try {
await client.syncLabelDefinition(label);
} catch (e) {
log(`Warning: Failed to bootstrap new label ${label}: ${e.message}`);
}
}
}
await client.addLabels(toAdd);
for (const label of toRemove) {
await client.removeLabel(label);
}
// Keep other label metadata consistent. This is best-effort trailing work.
for (const label of Object.keys(LABEL_DEFINITIONS)) {
if (toAdd.includes(label)) continue; // Already synced above
try {
await client.syncLabelDefinition(label);
} catch (e) {
log(`Warning: Failed to sync label definition for ${label}: ${e.message}`);
}
}
await writeStepSummary(prNumber, classification);
log(
`pr #${prNumber} type=${classification.prType} total_changes=${classification.totalChanges} `
+ `effective_changes=${classification.effectiveChanges} files=${files.length} `
+ `desired=${[...desired].sort().join(",") || "-"} current_managed=${managedCurrent.sort().join(",") || "-"} `
+ `reasons=${classification.reasons.join(" | ") || "-"}`,
);
}
main().catch((error) => {
log(error.message || String(error));
process.exit(1);
});
+145
View File
@@ -0,0 +1,145 @@
[
{
"name": "size-s-docs-badge",
"number": 103,
"title": "docs: add official badge to distinguish from third-party Lark CLI tools",
"pr_url": "https://github.com/larksuite/cli/pull/103",
"status": "merged",
"merged_at": "2026-03-30T12:15:45Z",
"expected_label": "size/S",
"expected_domains": [],
"review_note": "Pure docs sample. Useful to confirm low-risk paths stay in S even when total changed lines are not tiny."
},
{
"name": "size-s-docs-simplify",
"number": 26,
"title": "docs: simplify installation steps by merging CLI and Skills into one …",
"pr_url": "https://github.com/larksuite/cli/pull/26",
"status": "merged",
"merged_at": "2026-03-28T09:33:24Z",
"expected_label": "size/S",
"expected_domains": [],
"review_note": "Docs sample, verifying docs changes remain in S."
},
{
"name": "size-s-docs-star-history",
"number": 12,
"title": "docs: add Star History chart to readmes",
"pr_url": "https://github.com/larksuite/cli/pull/12",
"status": "merged",
"merged_at": "2026-03-28T16:00:15Z",
"expected_label": "size/S",
"expected_domains": [],
"review_note": "Docs sample, no effective business code changes."
},
{
"name": "size-s-docs-clarify-install",
"number": 3,
"title": "docs: clarify install methods and add source build steps",
"pr_url": "https://github.com/larksuite/cli/pull/3",
"status": "merged",
"merged_at": "2026-03-28T03:43:44Z",
"expected_label": "size/S",
"expected_domains": [],
"review_note": "Docs sample, pure documentation clarification."
},
{
"name": "size-m-fix-base-scope",
"number": 96,
"title": "fix(base): correct scope for record history list shortcut",
"pr_url": "https://github.com/larksuite/cli/pull/96",
"status": "merged",
"merged_at": "2026-03-30T11:40:18Z",
"expected_label": "size/M",
"expected_domains": ["domain/base"],
"review_note": "Small fix sample. Verify the lower edge of the M bucket within a single domain."
},
{
"name": "size-m-fix-mail-sensitive",
"number": 92,
"title": "fix: remove sensitive send scope from reply and forward shortcuts",
"pr_url": "https://github.com/larksuite/cli/pull/92",
"status": "merged",
"merged_at": "2026-03-30T10:19:11Z",
"expected_label": "size/M",
"expected_domains": ["domain/mail"],
"review_note": "Security-like wording in the title but stays in one business domain (mail)."
},
{
"name": "size-m-ci-improve",
"number": 71,
"title": "ci: improve CI workflows and add golangci-lint config",
"pr_url": "https://github.com/larksuite/cli/pull/71",
"status": "merged",
"merged_at": "2026-03-30T03:09:31Z",
"expected_label": "size/M",
"expected_domains": [],
"review_note": "CI workflow change that goes beyond S threshold."
},
{
"name": "size-m-feat-im-pagination",
"number": 30,
"title": "feat: add auto-pagination to messages search and update lark-im docs",
"pr_url": "https://github.com/larksuite/cli/pull/30",
"status": "merged",
"merged_at": "2026-03-30T15:00:41Z",
"expected_label": "size/M",
"expected_domains": ["domain/im"],
"review_note": "Single-domain feature with larger diff but effective changes stay in M."
},
{
"name": "size-l-fix-api-silent",
"number": 85,
"title": "fix: resolve silent failure in `lark-cli api` error output (#39)",
"pr_url": "https://github.com/larksuite/cli/pull/85",
"status": "merged",
"merged_at": "2026-03-30T09:19:24Z",
"expected_label": "size/L",
"expected_domains": [],
"review_note": "Touches core area (cmd), bumping the size to L."
},
{
"name": "size-l-fix-cli",
"number": 91,
"title": "fix: correct CLI examples in root help and READMEs (closes #48)",
"pr_url": "https://github.com/larksuite/cli/pull/91",
"status": "closed",
"merged_at": null,
"expected_label": "size/L",
"expected_domains": [],
"review_note": "Closed PR touching core area (cmd)."
},
{
"name": "size-m-skill-format-check",
"number": 134,
"title": "feat(ci): add skill format check workflow to ensure SKILL.md compliance",
"pr_url": "https://github.com/larksuite/cli/pull/134",
"status": "closed",
"merged_at": null,
"expected_label": "size/M",
"expected_domains": [],
"review_note": "Includes updates to tests/bad-skill/SKILL.md inside skills-like paths, testing how skill mock files and test scripts are handled."
},
{
"name": "size-l-ccm-multi-path",
"number": 57,
"title": "feat(docs): support local image upload in docs +create",
"pr_url": "https://github.com/larksuite/cli/pull/57",
"status": "closed",
"merged_at": null,
"expected_label": "size/L",
"expected_domains": ["domain/ccm"],
"review_note": "Touches docs_create_images.go and table_auto_width.go, representing multiple CCM sub-paths but resolving to a single ccm domain."
},
{
"name": "size-l-domain-rename",
"number": 11,
"title": "docs: rename user-facing Bitable references to Base",
"pr_url": "https://github.com/larksuite/cli/pull/11",
"status": "merged",
"merged_at": "2026-03-28T16:00:52Z",
"expected_label": "size/L",
"expected_domains": ["domain/base", "domain/ccm"],
"review_note": "A rename across paths. Since we track previous_filename to evaluate domains, this should properly capture the base domain."
}
]
+55
View File
@@ -0,0 +1,55 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require('fs');
const { execFileSync } = require('child_process');
const path = require('path');
const samplesPath = path.join(__dirname, 'samples.json');
const indexPath = path.join(__dirname, 'index.js');
const samples = JSON.parse(fs.readFileSync(samplesPath, 'utf8'));
if (!process.env.GITHUB_TOKEN) {
console.error("❌ Error: GITHUB_TOKEN environment variable is required to run tests without hitting API rate limits.");
console.error("Please run: GITHUB_TOKEN=$(gh auth token) node scripts/pr-labels/test.js");
process.exit(1);
}
let passed = 0;
let failed = 0;
for (const sample of samples) {
try {
const output = execFileSync(
process.execPath,
[indexPath, '--dry-run', '--json', '--pr-url', sample.pr_url],
{ encoding: 'utf8', env: process.env }
);
const result = JSON.parse(output);
const matchLabel = result.label === sample.expected_label;
// Sort before comparing to ignore order
const actualDomains = (result.businessDomains || []).sort();
const expectedDomains = (sample.expected_domains || []).map(d => d.replace('domain/', '')).sort();
const matchDomains = JSON.stringify(actualDomains) === JSON.stringify(expectedDomains);
if (matchLabel && matchDomains) {
console.log(`✅ Passed: ${sample.name}`);
passed++;
} else {
console.log(`❌ Failed: ${sample.name}`);
console.log(` Label expected: ${sample.expected_label}, got: ${result.label}`);
console.log(` Domains expected: ${expectedDomains}, got: ${actualDomains}`);
failed++;
}
} catch (e) {
console.log(`❌ Failed: ${sample.name} (Execution error)`);
console.error(e.message);
failed++;
}
}
console.log(`\nTest Summary: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
+193
View File
@@ -0,0 +1,193 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const SUMMARY_MARKER_PREFIX = "<!-- lark-cli-pr-quality-summary";
const LEGACY_SUMMARY_MARKER_PREFIXES = [
"<!-- lark-cli-semantic-review",
];
function sanitizeMarkdownBody(text) {
return String(text || "")
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
.replace(/[\r\n\t]+/g, " ")
.replace(/@/g, "@\u200b")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\\/g, "\\\\")
.replace(/`/g, "\\`")
.replace(/\*/g, "\\*")
.replace(/_/g, "\\_")
.replace(/#/g, "\\#")
.replace(/\|/g, "\\|")
.replace(/!/g, "\\!")
.replace(/\[/g, "\\[")
.replace(/\]/g, "\\]")
.replace(/\(/g, "\\(")
.replace(/\)/g, "\\)")
.replace(/\bhttps:\/\//g, "https[:]//")
.replace(/\bhttp:\/\//g, "http[:]//")
.split(/\s+/)
.filter(Boolean)
.join(" ");
}
function markdownText(value) {
return sanitizeMarkdownBody(String(value || ""));
}
function inlineCodeText(value) {
return String(value || "")
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
.replace(/[\r\n\t]+/g, " ")
.split(/\s+/)
.filter(Boolean)
.join(" ");
}
function inlineCode(value) {
const text = inlineCodeText(value);
const runs = text.match(/`+/g) || [];
const fence = "`".repeat(runs.reduce((max, run) => Math.max(max, run.length), 0) + 1);
const body = text.startsWith("`") || text.endsWith("`") ? ` ${text} ` : text;
return fence + body + fence;
}
function summaryMarker(target = {}) {
return `${SUMMARY_MARKER_PREFIX} head=${target.headSha || ""} base=${target.baseSha || ""} run=${target.runId || ""} -->`;
}
function parseSummaryMarker(body) {
const match = /<!--\s*lark-cli-(?:pr-quality-summary|semantic-review)\s+([^>]*)-->/.exec(String(body || ""));
if (!match) {
return {};
}
const metadata = {};
for (const part of match[1].trim().split(/\s+/)) {
const attr = /^([A-Za-z0-9_-]+)=([^ ]*)$/.exec(part);
if (attr) {
metadata[attr[1]] = attr[2];
}
}
return metadata;
}
function markerRunNumber(value) {
const run = Number(String(value || "").trim());
return Number.isInteger(run) && run > 0 ? run : 0;
}
function summaryCommentRunNumber(comment) {
return markerRunNumber(parseSummaryMarker(comment?.body).run);
}
function targetRunNumber(target) {
return markerRunNumber(target?.runId);
}
function hasNewerSummaryComment(comments, target) {
const currentRun = targetRunNumber(target);
return qualitySummaryComments(comments)
.some((comment) => summaryCommentRunNumber(comment) > currentRun);
}
function isBotComment(comment) {
return !!(comment && comment.user && comment.user.type === "Bot");
}
function hasQualitySummaryMarker(body) {
const text = String(body || "");
return text.includes(SUMMARY_MARKER_PREFIX) ||
LEGACY_SUMMARY_MARKER_PREFIXES.some((prefix) => text.includes(prefix));
}
function qualitySummaryComments(comments) {
return (Array.isArray(comments) ? comments : [])
.filter((comment) => isBotComment(comment) && hasQualitySummaryMarker(comment.body));
}
function findQualitySummaryComment(comments) {
return qualitySummaryComments(comments)[0] || null;
}
function finalSummaryBody(target, markdown) {
return `${summaryMarker(target)}\n${String(markdown || "")}`.slice(0, 60000);
}
async function listIssueComments(github, context, pr) {
return github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr,
per_page: 100,
});
}
async function publishQualitySummary({ github, context, pr, target, markdown, beforeWrite }) {
const body = finalSummaryBody(target, markdown);
const comments = await listIssueComments(github, context, pr);
const summaries = qualitySummaryComments(comments);
if (hasNewerSummaryComment(summaries, target)) {
return { action: "skipped-newer-summary" };
}
const existing = summaries[0] || null;
if (beforeWrite && !(await beforeWrite(existing ? "update" : "creation"))) {
return { action: "skipped" };
}
for (const duplicate of summaries.slice(1)) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: duplicate.id,
});
}
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
return { action: "updated", commentId: existing.id, body };
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr,
body,
});
return { action: "created", body };
}
async function deleteQualitySummaries({ github, context, pr, target, beforeWrite }) {
const comments = await listIssueComments(github, context, pr);
const existing = qualitySummaryComments(comments);
if (hasNewerSummaryComment(existing, target)) {
return { deleted: 0, skipped: true };
}
if (existing.length > 0 && beforeWrite && !(await beforeWrite("delete"))) {
return { deleted: 0, skipped: true };
}
for (const comment of existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id,
});
}
return { deleted: existing.length };
}
module.exports = {
SUMMARY_MARKER_PREFIX,
deleteQualitySummaries,
finalSummaryBody,
findQualitySummaryComment,
hasQualitySummaryMarker,
inlineCode,
listIssueComments,
markdownText,
publishQualitySummary,
qualitySummaryComments,
sanitizeMarkdownBody,
summaryMarker,
};
+230
View File
@@ -0,0 +1,230 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const {
deleteQualitySummaries,
finalSummaryBody,
findQualitySummaryComment,
hasQualitySummaryMarker,
markdownText,
publishQualitySummary,
qualitySummaryComments,
summaryMarker,
inlineCode,
} = require("./pr-quality-summary.js");
describe("pr-quality-summary", () => {
it("writes a current PR quality summary marker", () => {
const marker = summaryMarker({
headSha: "0123456789abcdef0123456789abcdef01234567",
baseSha: "fedcba9876543210fedcba9876543210fedcba98",
runId: "123",
});
assert.equal(
marker,
"<!-- lark-cli-pr-quality-summary head=0123456789abcdef0123456789abcdef01234567 base=fedcba9876543210fedcba9876543210fedcba98 run=123 -->",
);
});
it("recognizes current and legacy bot summary comments", () => {
const comments = [
{ id: 1, user: { type: "User" }, body: "<!-- lark-cli-pr-quality-summary head=a -->" },
{ id: 2, user: { type: "Bot" }, body: "plain comment" },
{ id: 3, user: { type: "Bot" }, body: "<!-- lark-cli-semantic-review head=old -->" },
{ id: 4, user: { type: "Bot" }, body: "<!-- lark-cli-pr-quality-summary head=new -->" },
];
assert.equal(hasQualitySummaryMarker(comments[3].body), true);
assert.deepEqual(qualitySummaryComments(comments).map((c) => c.id), [3, 4]);
assert.equal(findQualitySummaryComment(comments).id, 3);
});
it("creates a summary when no existing marker is present", async () => {
const calls = { comments: [], order: [] };
await publishQualitySummary({
github: fakeGithub(calls),
context: context(),
pr: 42,
target: target(),
markdown: "## PR Quality Summary\n\n- fix this",
});
assert.equal(calls.comments.length, 1);
assert.equal(calls.comments[0].issue_number, 42);
assert.match(calls.comments[0].body, /^<!-- lark-cli-pr-quality-summary /);
assert.match(calls.comments[0].body, /## PR Quality Summary/);
});
it("updates a legacy summary instead of creating a second stable comment", async () => {
const calls = { comments: [], order: [] };
await publishQualitySummary({
github: fakeGithub(calls, {
issueComments: [{
id: 99,
user: { type: "Bot" },
body: "<!-- lark-cli-semantic-review head=old base=old run=1 -->",
}],
}),
context: context(),
pr: 42,
target: target(),
markdown: "## PR Quality Summary\n\n- updated",
});
assert.equal(calls.comments.length, 1);
assert.equal(calls.comments[0].comment_id, 99);
assert.equal(calls.comments[0].issue_number, undefined);
assert.match(calls.comments[0].body, /^<!-- lark-cli-pr-quality-summary /);
});
it("removes duplicate summary comments when publishing a new body", async () => {
const calls = { comments: [], deletedComments: [], order: [] };
await publishQualitySummary({
github: fakeGithub(calls, {
issueComments: [
{ id: 99, user: { type: "Bot" }, body: "<!-- lark-cli-semantic-review head=old -->" },
{ id: 100, user: { type: "Bot" }, body: "<!-- lark-cli-pr-quality-summary head=new -->" },
],
}),
context: context(),
pr: 42,
target: target(),
markdown: "## PR Quality Summary\n\n- updated",
});
assert.equal(calls.comments.length, 1);
assert.equal(calls.comments[0].comment_id, 99);
assert.deepEqual(calls.deletedComments.map((c) => c.comment_id), [100]);
});
it("does not let an older run overwrite a newer summary", async () => {
const calls = { comments: [], deletedComments: [], order: [] };
const result = await publishQualitySummary({
github: fakeGithub(calls, {
issueComments: [{
id: 99,
user: { type: "Bot" },
body: "<!-- lark-cli-pr-quality-summary head=0123456789abcdef0123456789abcdef01234567 base=fedcba9876543210fedcba9876543210fedcba98 run=123457 -->",
}],
}),
context: context(),
pr: 42,
target: target(),
markdown: "## PR Quality Summary\n\n- older",
});
assert.equal(result.action, "skipped-newer-summary");
assert.equal(calls.comments.length, 0);
assert.equal(calls.deletedComments.length, 0);
});
it("deletes all current and legacy summaries during clean no-action runs", async () => {
const calls = { deletedComments: [] };
const result = await deleteQualitySummaries({
github: fakeGithub(calls, {
issueComments: [
{ id: 10, user: { type: "Bot" }, body: "<!-- lark-cli-semantic-review head=old -->" },
{ id: 11, user: { type: "Bot" }, body: "<!-- lark-cli-pr-quality-summary head=new -->" },
{ id: 12, user: { type: "Bot" }, body: "unrelated" },
],
}),
context: context(),
pr: 42,
target: target(),
});
assert.equal(result.deleted, 2);
assert.deepEqual(calls.deletedComments.map((c) => c.comment_id), [10, 11]);
});
it("does not let an older cleanup delete a newer summary", async () => {
const calls = { deletedComments: [] };
const result = await deleteQualitySummaries({
github: fakeGithub(calls, {
issueComments: [{
id: 99,
user: { type: "Bot" },
body: "<!-- lark-cli-pr-quality-summary head=0123456789abcdef0123456789abcdef01234567 base=fedcba9876543210fedcba9876543210fedcba98 run=123457 -->",
}],
}),
context: context(),
pr: 42,
target: target(),
});
assert.equal(result.skipped, true);
assert.equal(calls.deletedComments.length, 0);
});
it("sanitizes model-controlled text for markdown summaries", () => {
const got = markdownText("@team\n# forged [link](https://example.com)<b>");
assert(!got.includes("@team"));
assert(!got.includes("\n# forged"));
assert(!got.includes("https://example.com"));
assert(!got.includes("<b>"));
assert(got.includes("@\u200bteam"));
assert(got.includes("\\# forged"));
assert(got.includes("https[:]//example.com"));
assert(got.includes("&lt;b&gt;"));
});
it("keeps inline code labels on one markdown line", () => {
const got = inlineCode("abc\n\n## INJECTED\n\n[x](http://evil)\t@team\u0001");
assert.equal(got, "`abc ## INJECTED [x](http://evil) @team`");
assert(!got.includes("\n"));
assert(!got.includes("\t"));
assert(!got.includes("\u0001"));
});
it("caps final summary body size", () => {
const body = finalSummaryBody(target(), "x".repeat(70000));
assert.equal(body.length, 60000);
assert.match(body, /^<!-- lark-cli-pr-quality-summary /);
});
});
function context() {
return { repo: { owner: "larksuite", repo: "cli" } };
}
function target() {
return {
headSha: "0123456789abcdef0123456789abcdef01234567",
baseSha: "fedcba9876543210fedcba9876543210fedcba98",
runId: "123456",
};
}
function fakeGithub(calls, options = {}) {
const api = {
paginate: async (endpoint) => {
if (endpoint === api.rest.issues.listComments) {
return options.issueComments || [];
}
return [];
},
rest: {
issues: {
listComments() {},
createComment: async (args) => {
calls.comments.push(args);
calls.order?.push("comment");
},
updateComment: async (args) => {
calls.comments.push(args);
calls.order?.push("comment");
},
deleteComment: async (args) => {
calls.deletedComments.push(args);
},
},
},
};
return api;
}
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
set -euo pipefail
if root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
cd "$root"
fi
is_zero_sha() {
[[ "$1" =~ ^0{40}$ ]]
}
commit_exists() {
git cat-file -e "$1^{commit}" 2>/dev/null
}
is_head_ancestor() {
git merge-base --is-ancestor "$1" HEAD 2>/dev/null
}
merge_base_with_head() {
git merge-base "$1" HEAD 2>/dev/null
}
candidate="${QUALITY_GATE_CHANGED_FROM:-}"
if [[ -n "$candidate" ]] && ! is_zero_sha "$candidate"; then
if commit_exists "$candidate"; then
if is_head_ancestor "$candidate"; then
printf '%s\n' "$candidate"
exit 0
fi
if base="$(merge_base_with_head "$candidate")"; then
printf '%s\n' "$base"
exit 0
fi
fi
fi
if commit_exists origin/main; then
if base="$(merge_base_with_head origin/main)"; then
printf '%s\n' "$base"
exit 0
fi
fi
if git rev-parse --verify --quiet HEAD~1 >/dev/null; then
printf '%s\n' "HEAD~1"
exit 0
fi
printf '%s\n' "HEAD"
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
script="$repo_root/scripts/resolve-changed-from.sh"
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
cleanup_tmp() {
local attempt
for attempt in 1 2 3; do
rm -rf "$tmp" && return 0
sleep 1
done
rm -rf "$tmp"
}
trap cleanup_tmp EXIT
mkdir -p "$tmp"
git_init() {
local dir="$1"
git init -q -b main "$dir"
git -C "$dir" config user.name test
git -C "$dir" config user.email test@example.com
}
commit_file() {
local dir="$1"
local name="$2"
local content="$3"
printf '%s\n' "$content" >"$dir/$name"
git -C "$dir" add "$name"
git -C "$dir" commit -q -m "$content"
}
test_uses_candidate_when_it_is_head_ancestor() {
local dir="$tmp/ancestor"
git_init "$dir"
commit_file "$dir" file.txt base
local base
base="$(git -C "$dir" rev-parse HEAD)"
commit_file "$dir" file.txt head
local got
got="$(cd "$dir" && QUALITY_GATE_CHANGED_FROM="$base" bash "$script")"
if [[ "$got" != "$base" ]]; then
echo "ancestor candidate = $got, want $base" >&2
return 1
fi
}
test_uses_merge_base_when_candidate_is_related_but_not_head_ancestor() {
local dir="$tmp/non-ancestor"
git_init "$dir"
commit_file "$dir" file.txt base
local base
base="$(git -C "$dir" rev-parse HEAD)"
git -C "$dir" checkout -q -b old
commit_file "$dir" old.txt old
local old
old="$(git -C "$dir" rev-parse HEAD)"
git -C "$dir" checkout -q main
commit_file "$dir" file.txt head-1
commit_file "$dir" file.txt head-2
local got
got="$(cd "$dir" && QUALITY_GATE_CHANGED_FROM="$old" bash "$script")"
if [[ "$got" != "$base" ]]; then
echo "non-ancestor candidate = $got, want merge-base $base" >&2
return 1
fi
}
test_uses_origin_main_merge_base_when_candidate_is_missing() {
local dir="$tmp/origin-main"
git_init "$dir"
commit_file "$dir" file.txt base
local base
base="$(git -C "$dir" rev-parse HEAD)"
git -C "$dir" branch feature
commit_file "$dir" file.txt main
git -C "$dir" update-ref refs/remotes/origin/main HEAD
git -C "$dir" checkout -q feature
commit_file "$dir" feature.txt feature-1
commit_file "$dir" feature.txt feature-2
local got
got="$(cd "$dir" && bash "$script")"
if [[ "$got" != "$base" ]]; then
echo "missing candidate = $got, want origin/main merge-base $base" >&2
return 1
fi
}
test_falls_back_from_zero_sha() {
local dir="$tmp/zero"
git_init "$dir"
commit_file "$dir" file.txt base
commit_file "$dir" file.txt head
local got
got="$(cd "$dir" && QUALITY_GATE_CHANGED_FROM="0000000000000000000000000000000000000000" bash "$script")"
if [[ "$got" != "HEAD~1" ]]; then
echo "zero candidate = $got, want HEAD~1" >&2
return 1
fi
}
test_uses_candidate_when_it_is_head_ancestor
test_uses_merge_base_when_candidate_is_related_but_not_head_ancestor
test_uses_origin_main_merge_base_when_candidate_is_missing
test_falls_back_from_zero_sha
Executable
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const { execFileSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const ext = process.platform === "win32" ? ".exe" : "";
const bin = path.join(__dirname, "..", "bin", "lark-cli" + ext);
// On Windows, a crashed self-update may have left the binary renamed to .old.
// Recover it before proceeding so the CLI remains functional.
const oldBin = bin + ".old";
function restoreOldBinary() {
try {
if (fs.existsSync(bin)) {
fs.rmSync(bin, { force: true });
}
fs.renameSync(oldBin, bin);
return true;
} catch (_) {
return false;
}
}
if (process.platform === "win32" && fs.existsSync(oldBin)) {
if (!fs.existsSync(bin)) {
restoreOldBinary();
} else {
try {
execFileSync(bin, ["--version"], { stdio: "ignore", timeout: 10000 });
try {
fs.rmSync(oldBin, { force: true });
} catch (_) {
// Best-effort cleanup; keep running the healthy binary.
}
} catch (_) {
restoreOldBinary();
}
}
}
// Intercept "install" subcommand — run the setup wizard directly,
// bypassing the native binary (which may not exist yet under npx).
const args = process.argv.slice(2);
if (args[0] === "install") {
require("./install-wizard.js");
} else {
// Auto-download binary if missing (e.g. npx skipped postinstall).
if (!fs.existsSync(bin)) {
try {
execFileSync(process.execPath, [path.join(__dirname, "install.js")], {
stdio: "inherit",
env: { ...process.env, LARK_CLI_RUN: "true" },
});
} catch (_) {
console.error(
`\nFailed to auto-install lark-cli binary.\n` +
`To fix, run the install script manually:\n` +
` node "${path.join(__dirname, "install.js")}"\n`
);
process.exit(1);
}
}
try {
execFileSync(bin, args, { stdio: "inherit" });
} catch (e) {
process.exit(e.status || 1);
}
}
+995
View File
@@ -0,0 +1,995 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("fs");
const crypto = require("crypto");
const {
deleteQualitySummaries,
publishQualitySummary,
} = require("./pr-quality-summary.js");
function readText(path, fallback) {
try {
return fs.readFileSync(path, "utf8");
} catch {
return fallback;
}
}
function sanitizeMarkdownBody(text) {
return String(text || "")
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
.replace(/[\r\n\t]+/g, " ")
.replace(/@/g, "@\u200b")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\\/g, "\\\\")
.replace(/`/g, "\\`")
.replace(/\*/g, "\\*")
.replace(/_/g, "\\_")
.replace(/#/g, "\\#")
.replace(/\|/g, "\\|")
.replace(/!/g, "\\!")
.replace(/\[/g, "\\[")
.replace(/\]/g, "\\]")
.replace(/\(/g, "\\(")
.replace(/\)/g, "\\)")
.replace(/\bhttps:\/\//g, "https[:]//")
.replace(/\bhttp:\/\//g, "http[:]//")
.split(/\s+/)
.filter(Boolean)
.join(" ");
}
function parseBlockMode(value) {
return value === "true";
}
function checkName(runtimeBlockMode) {
return runtimeBlockMode ? "semantic-review/result" : "semantic-review/observe";
}
function infrastructureFailureDecision(message) {
return {
degraded: true,
infrastructure_failure: true,
system_warnings: [{
severity: "critical",
message,
suggested_action: "inspect semantic-review workflow logs and quality-gate artifact",
}],
blockers: [],
warnings: [],
};
}
function validateDecisionShape(decision) {
if (!decision || typeof decision !== "object" || Array.isArray(decision)) {
return "semantic review decision must be an object";
}
if (typeof decision.block_mode !== "boolean") {
return "semantic review decision block_mode must be boolean";
}
if ("degraded" in decision && typeof decision.degraded !== "boolean") {
return "semantic review decision degraded must be boolean";
}
if ("skipped" in decision && typeof decision.skipped !== "boolean") {
return "semantic review decision skipped must be boolean";
}
if ("infrastructure_failure" in decision && typeof decision.infrastructure_failure !== "boolean") {
return "semantic review decision infrastructure_failure must be boolean";
}
if ("blockers" in decision && !Array.isArray(decision.blockers)) {
return "semantic review decision blockers must be an array";
}
if ("warnings" in decision && !Array.isArray(decision.warnings)) {
return "semantic review decision warnings must be an array";
}
if ("system_warnings" in decision && !Array.isArray(decision.system_warnings)) {
return "semantic review decision system_warnings must be an array";
}
if (!("blockers" in decision)) {
decision.blockers = [];
}
if (!("warnings" in decision)) {
decision.warnings = [];
}
return "";
}
function loadDecision(path = "decision.json") {
const raw = readText(path, "");
if (!raw) {
return infrastructureFailureDecision("semantic review decision is missing");
}
try {
const decision = JSON.parse(raw);
const shapeError = validateDecisionShape(decision);
if (shapeError) {
return infrastructureFailureDecision(shapeError);
}
return decision;
} catch (err) {
return infrastructureFailureDecision(`semantic review decision is invalid JSON: ${err.message}`);
}
}
function checkConclusion(decision, runtimeBlockMode) {
if (typeof decision.block_mode === "boolean" && decision.block_mode !== runtimeBlockMode) {
return runtimeBlockMode ? "failure" : "neutral";
}
const systemWarnings = Array.isArray(decision?.system_warnings) ? decision.system_warnings : [];
if (systemWarnings.length > 0) {
return runtimeBlockMode ? "failure" : "neutral";
}
if (decision.infrastructure_failure) {
return runtimeBlockMode ? "failure" : "neutral";
}
if (decision.skipped) {
return runtimeBlockMode ? "failure" : "neutral";
}
if (decision.degraded) {
return runtimeBlockMode ? "failure" : "neutral";
}
if (runtimeBlockMode && Array.isArray(decision.blockers) && decision.blockers.length > 0) {
return "failure";
}
return "success";
}
function loadFacts(path = "facts.json") {
const raw = readText(path, "");
if (!raw) {
return {};
}
try {
const facts = JSON.parse(raw);
if (!facts || typeof facts !== "object" || Array.isArray(facts)) {
return {};
}
return facts;
} catch {
return {};
}
}
function markdownText(value) {
return sanitizeMarkdownBody(String(value || ""));
}
function inlineCodeText(value) {
return String(value || "")
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
.replace(/[\r\n\t]+/g, " ")
.split(/\s+/)
.filter(Boolean)
.join(" ");
}
function inlineCode(value) {
const text = inlineCodeText(value);
const runs = text.match(/`+/g) || [];
const fence = "`".repeat(runs.reduce((max, run) => Math.max(max, run.length), 0) + 1);
const body = text.startsWith("`") || text.endsWith("`") ? ` ${text} ` : text;
return fence + body + fence;
}
function parseEvidenceRef(ref) {
const match = /^facts\.(commands|skills|errors|outputs|public_content)\[(\d+)\]$/.exec(String(ref || ""));
if (!match) {
return null;
}
return { kind: match[1], index: Number(match[2]) };
}
function evidenceLocation(facts, ref) {
const parsed = parseEvidenceRef(ref);
if (!parsed) {
return null;
}
const items = Array.isArray(facts?.[parsed.kind]) ? facts[parsed.kind] : [];
const item = items[parsed.index];
if (!item || typeof item !== "object") {
return null;
}
switch (parsed.kind) {
case "skills":
if (item.source_file && Number.isInteger(item.line) && item.line > 0) {
return {
kind: parsed.kind,
path: item.source_file,
line: item.line,
label: `${item.source_file}:${item.line}`,
};
}
if (item.command_path) {
return { kind: parsed.kind, command: item.command_path, label: item.command_path };
}
return null;
case "errors":
if (item.file && Number.isInteger(item.line) && item.line > 0) {
return {
kind: parsed.kind,
path: item.file,
line: item.line,
label: `${item.file}:${item.line}`,
};
}
if (item.command_path || item.command) {
const command = item.command_path || item.command;
return { kind: parsed.kind, command, label: command };
}
return null;
case "outputs":
if (item.command) {
return { kind: parsed.kind, command: item.command, label: item.command };
}
return null;
case "commands":
if (item.path) {
return { kind: parsed.kind, command: item.path, label: item.path };
}
return null;
case "public_content":
if (item.file && Number.isInteger(item.line) && item.line > 0) {
const label = `${item.file}:${item.line}`;
if (item.file === "branch" || item.file === "pull_request_metadata" || String(item.file).startsWith("commit:")) {
return { kind: parsed.kind, label };
}
return {
kind: parsed.kind,
path: item.file,
line: item.line,
label,
};
}
return null;
default:
return null;
}
}
function resolveFindingEvidence(facts, finding) {
const evidence = Array.isArray(finding?.evidence) ? finding.evidence : [];
return evidence
.map((ref) => evidenceLocation(facts, ref))
.filter(Boolean);
}
function changedLinesFromPatch(patch) {
const changed = new Set();
if (typeof patch !== "string" || patch === "") {
return changed;
}
let rightLine = 0;
for (const line of patch.split("\n")) {
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
if (hunk) {
rightLine = Number(hunk[1]);
continue;
}
if (rightLine <= 0 || line.startsWith("\\ No newline")) {
continue;
}
if (line.startsWith("+") && !line.startsWith("+++")) {
changed.add(rightLine);
rightLine++;
continue;
}
if (line.startsWith("-") && !line.startsWith("---")) {
continue;
}
rightLine++;
}
return changed;
}
function buildChangedLineIndex(files) {
const index = new Map();
for (const file of Array.isArray(files) ? files : []) {
if (!file || typeof file.filename !== "string") {
continue;
}
index.set(file.filename, changedLinesFromPatch(file.patch || ""));
}
return index;
}
function selectInlineTarget(finding, facts, changedLineIndex) {
const evidence = resolveFindingEvidence(facts, finding);
for (const item of evidence) {
if (!item.path || !Number.isInteger(item.line) || item.line <= 0) {
continue;
}
const changed = changedLineIndex instanceof Map ? changedLineIndex.get(item.path) : null;
if (changed && changed.has(item.line)) {
return { path: item.path, line: item.line };
}
}
return null;
}
function findingActionGroup(finding) {
if (finding && typeof finding.review_action === "string") {
switch (finding.review_action) {
case "must_fix":
return "must_fix";
case "confirm":
return "confirm";
case "observe":
return "observe";
default:
break;
}
}
return "";
}
function findingStatusLabel(finding) {
switch (findingActionGroup(finding)) {
case "must_fix":
return "Must fix";
case "confirm":
return "Confirm";
case "observe":
return "Observe";
default:
return "Review";
}
}
function validatePublishFinding(finding, listName, index) {
const location = `${listName}[${index}]`;
const action = findingActionGroup(finding);
if (!action) {
return `${location} missing review_action`;
}
if (typeof finding?.fingerprint !== "string" || finding.fingerprint.trim() === "") {
return `${location} missing fingerprint`;
}
if (listName === "blockers" && action !== "must_fix") {
return `${location} review_action must be must_fix`;
}
if (listName === "warnings" && action === "must_fix") {
return `${location} review_action must not be must_fix`;
}
return "";
}
function validateDecisionForPublish(decision) {
const blockers = Array.isArray(decision?.blockers) ? decision.blockers : [];
const warnings = Array.isArray(decision?.warnings) ? decision.warnings : [];
for (let i = 0; i < blockers.length; i++) {
const err = validatePublishFinding(blockers[i], "blockers", i);
if (err) {
return `semantic review decision finding ${err}`;
}
}
for (let i = 0; i < warnings.length; i++) {
const err = validatePublishFinding(warnings[i], "warnings", i);
if (err) {
return `semantic review decision finding ${err}`;
}
}
return "";
}
function publishableDecision(decision, runtimeBlockMode) {
const err = validateDecisionForPublish(decision);
if (!err) {
return decision;
}
const failed = infrastructureFailureDecision(err);
failed.block_mode = runtimeBlockMode;
return failed;
}
function findingLine(finding, facts, inlineState) {
const evidence = resolveFindingEvidence(facts, finding);
const evidenceText = evidence.length > 0
? evidence.map((item) => inlineCode(item.label)).join(", ")
: "not mapped to a source location";
const key = findingKey(finding, facts);
const inline = inlineState instanceof Map && key ? inlineState.get(key) : null;
const parts = [
`**${markdownText(finding?.category || "finding")}**`,
markdownText(finding?.message || ""),
];
if (finding?.suggested_action) {
parts.push(`Action: ${markdownText(finding.suggested_action)}`);
}
parts.push(`Evidence: ${evidenceText}`);
if (finding?.waiver_id) {
parts.push(`Exception: ${inlineCode(finding.waiver_id)}`);
}
if (inline?.label) {
parts.push(`Inline: semantic review ${inline.label}`);
}
return `- ${parts.filter(Boolean).join(" — ")}`;
}
function appendFindingSection(lines, title, findings, facts, inlineState) {
if (findings.length === 0) {
return;
}
lines.push(`### ${title}`, "");
for (const finding of findings) {
lines.push(findingLine(finding, facts, inlineState));
}
lines.push("");
}
function buildSummaryMarkdown(decision, facts = {}, inlineState = new Map()) {
const blockers = Array.isArray(decision?.blockers) ? decision.blockers : [];
const warnings = Array.isArray(decision?.warnings) ? decision.warnings : [];
const systemWarnings = Array.isArray(decision?.system_warnings) ? decision.system_warnings : [];
const grouped = {
must_fix: blockers.filter((finding) => findingActionGroup(finding) === "must_fix"),
confirm: [],
observe: [],
};
for (const finding of warnings) {
const action = findingActionGroup(finding);
if (action === "confirm") {
grouped.confirm.push(finding);
} else {
grouped.observe.push(finding);
}
}
const counts = findingActionCounts(decision);
const lines = ["## PR Quality Summary", ""];
if (counts.mustFix > 0) {
lines.push("This PR has items that need changes before merge.", "");
} else if (counts.confirm > 0) {
lines.push("This PR has items that need confirmation. They do not block this PR.", "");
} else if (counts.systemWarnings > 0 || decision?.infrastructure_failure || decision?.degraded || decision?.skipped) {
lines.push("The semantic review system could not produce a fully trusted result. This is not reported as a code defect.", "");
} else {
lines.push("No action required.", "");
}
appendFindingSection(lines, "Must fix", grouped.must_fix, facts, inlineState);
appendFindingSection(lines, "Confirm", grouped.confirm, facts, inlineState);
if (systemWarnings.length > 0) {
lines.push("### System status", "");
for (const warning of systemWarnings) {
const parts = [markdownText(warning?.message || "")];
if (warning?.suggested_action) {
parts.push(`Action: ${markdownText(warning.suggested_action)}`);
}
lines.push(`- ${parts.filter(Boolean).join(" — ")}`);
}
lines.push("");
}
if (counts.mustFix > 0) {
lines.push("Resolving an inline discussion only closes the conversation. To change the check result, update the PR or land a recorded exception and rerun checks.");
}
return lines.join("\n");
}
function findingActionCounts(decision) {
const blockers = Array.isArray(decision?.blockers) ? decision.blockers : [];
const warnings = Array.isArray(decision?.warnings) ? decision.warnings : [];
const systemWarnings = Array.isArray(decision?.system_warnings) ? decision.system_warnings : [];
const counts = {
mustFix: blockers.filter((finding) => findingActionGroup(finding) === "must_fix").length,
confirm: 0,
observe: 0,
systemWarnings: systemWarnings.length,
};
for (const finding of warnings) {
if (findingActionGroup(finding) === "confirm") {
counts.confirm++;
} else {
counts.observe++;
}
}
return counts;
}
function buildCheckSummary(decision, conclusion) {
const options = arguments.length > 2 && arguments[2] ? arguments[2] : {};
const counts = findingActionCounts(decision);
const lines = [
`Result: ${conclusion}.`,
`Must fix: ${counts.mustFix}. Confirm: ${counts.confirm}. Observe: ${counts.observe}. System warnings: ${counts.systemWarnings}.`,
];
if (options.summaryPublicationError) {
lines.push(`PR Quality Summary publication failed: ${options.summaryPublicationError}.`);
} else if (options.summaryRequired) {
lines.push("See the PR Quality Summary for action-required findings. Observe-only findings are not published as PR comments.");
} else {
lines.push("No PR Quality Summary was published because there are no required actions.");
}
if (options.inlineFailureCount > 0) {
lines.push(`Inline comment publication failures: ${options.inlineFailureCount}.`);
}
return lines.join("\n");
}
function hasSystemProblem(decision, runtimeBlockMode) {
const systemWarnings = Array.isArray(decision?.system_warnings) ? decision.system_warnings : [];
if (systemWarnings.length > 0 || decision?.infrastructure_failure || decision?.skipped || decision?.degraded) {
return true;
}
return typeof decision?.block_mode === "boolean" && decision.block_mode !== runtimeBlockMode;
}
function semanticSummaryRequired(decision, runtimeBlockMode) {
const counts = findingActionCounts(decision);
if (hasSystemProblem(decision, runtimeBlockMode)) {
return true;
}
if (counts.confirm > 0) {
return true;
}
return runtimeBlockMode && counts.mustFix > 0;
}
function buildCheckTitle(decision, conclusion, runtimeBlockMode, options = {}) {
if (options.summaryPublicationError) {
return "Semantic review publication failure";
}
if (hasSystemProblem(decision, runtimeBlockMode)) {
return "Semantic review system problem";
}
if (conclusion === "failure") {
return "Semantic review blockers";
}
return "Semantic review";
}
function inlineFailureCount(inlineState) {
if (!(inlineState instanceof Map)) {
return 0;
}
let failures = 0;
for (const state of inlineState.values()) {
if (state && state.failed) {
failures++;
}
}
return failures;
}
function stableEvidenceIdentity(facts, ref) {
const location = evidenceLocation(facts, ref);
if (!location) {
return `ref:${String(ref || "")}`;
}
if (location.path && Number.isInteger(location.line) && location.line > 0) {
return `path:${location.path}:${location.line}`;
}
if (location.command) {
return `command:${location.command}`;
}
return `label:${location.label || ""}`;
}
function stableFindingIdentity(finding, facts) {
const fingerprint = String(finding?.fingerprint || "").trim();
if (fingerprint !== "") {
return `fingerprint:${fingerprint}`;
}
const evidence = Array.isArray(finding?.evidence) ? finding.evidence : [];
return `evidence:${evidence.map((ref) => stableEvidenceIdentity(facts, ref)).sort().join("|")}`;
}
function findingKey(finding, facts = {}) {
const payload = JSON.stringify({
category: finding?.category || "",
identity: stableFindingIdentity(finding, facts),
});
return crypto.createHash("sha1").update(payload).digest("hex").slice(0, 16);
}
function findingMarker(key) {
return `<!-- lark-cli-semantic-finding:${key} -->`;
}
function markerKeyFromBody(body) {
const match = /<!--\s*lark-cli-semantic-finding:([a-f0-9]{8,40})\s*-->/.exec(String(body || ""));
return match ? match[1] : "";
}
function inlineCommentBody(finding, facts, target) {
const key = findingKey(finding, facts);
const evidence = resolveFindingEvidence(facts, finding);
const evidenceText = evidence.length > 0
? evidence.map((item) => inlineCode(item.label)).join(", ")
: "not mapped to a source location";
return [
findingMarker(key),
`**Semantic Review: ${findingStatusLabel(finding)}**`,
"",
`**${markdownText(finding?.category || "finding")}**: ${markdownText(finding?.message || "")}`,
"",
`Status: ${findingStatusLabel(finding)}`,
finding?.suggested_action ? `Action: ${markdownText(finding.suggested_action)}` : "",
`Evidence: ${evidenceText}`,
finding?.waiver_id ? `Exception: ${inlineCode(finding.waiver_id)}` : "",
"",
`This comment is anchored to ${inlineCode(`${target.path}:${target.line}`)}. Resolving this discussion does not change the failed check. Commit a fix or add an approved semantic-review waiver, then rerun CI.`,
].filter((line) => line !== "").join("\n");
}
function inlineCandidates(decision, runtimeBlockMode) {
if (!runtimeBlockMode) {
return [];
}
const blockers = Array.isArray(decision?.blockers) ? decision.blockers : [];
return blockers.filter((finding) => findingActionGroup(finding) === "must_fix");
}
function threadStateFromComment(comment, isResolved) {
const key = markerKeyFromBody(comment?.body);
if (!key) {
return null;
}
const path = comment?.path || "";
const line = Number(comment?.line || 0);
const location = path && line > 0 ? ` at ${inlineCode(`${path}:${line}`)}` : "";
const resolutionKnown = arguments.length >= 2;
const label = resolutionKnown
? `reused existing ${isResolved ? "resolved" : "unresolved"} discussion${location}`
: `reused existing discussion with unknown resolution${location}`;
return {
key,
commentId: Number(comment?.databaseId || comment?.id || 0),
body: String(comment?.body || ""),
path,
line,
location,
label,
resolutionKnown,
resolved: !!isResolved,
};
}
function isBotReviewComment(comment) {
const restUser = comment?.user;
if (restUser?.type === "Bot") {
return true;
}
const graphqlAuthor = comment?.author;
return graphqlAuthor?.__typename === "Bot";
}
async function loadExistingInlineThreads(github, context, core, pr) {
const existing = new Map();
if (typeof github.graphql === "function") {
try {
let cursor = null;
for (;;) {
const result = await github.graphql(`
query($owner: String!, $repo: String!, $number: Int!, $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
reviewThreads(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
isResolved
comments(first: 50) {
nodes {
databaseId
body
path
line
author {
__typename
login
}
}
}
}
}
}
}
}
`, {
owner: context.repo.owner,
repo: context.repo.repo,
number: pr,
cursor,
});
const threads = result?.repository?.pullRequest?.reviewThreads;
for (const thread of threads?.nodes || []) {
for (const comment of thread?.comments?.nodes || []) {
if (!isBotReviewComment(comment)) {
continue;
}
const state = threadStateFromComment(comment, thread.isResolved);
if (state && (!existing.has(state.key) || (existing.get(state.key).resolved && !state.resolved))) {
existing.set(state.key, state);
}
}
}
if (!threads?.pageInfo?.hasNextPage) {
break;
}
cursor = threads.pageInfo.endCursor;
}
return existing;
} catch (err) {
core.warning(`semantic review thread state was not read: ${err.message}`);
}
}
try {
const comments = await github.paginate(github.rest.pulls.listReviewComments, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr,
per_page: 100,
});
for (const comment of comments) {
if (!isBotReviewComment(comment)) {
continue;
}
const state = threadStateFromComment(comment);
if (state && (!existing.has(state.key) || (existing.get(state.key).resolved && !state.resolved))) {
existing.set(state.key, state);
}
}
} catch (err) {
core.warning(`semantic review review comments were not listed: ${err.message}`);
}
return existing;
}
async function loadChangedLineIndex(github, context, pr) {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr,
per_page: 100,
});
return buildChangedLineIndex(files);
}
async function publishInlineComments({ github, context, core, target: publishTarget, pr, headSha, decision, facts, runtimeBlockMode }) {
const inlineState = new Map();
const candidates = inlineCandidates(decision, runtimeBlockMode);
if (candidates.length === 0) {
return inlineState;
}
let changedLineIndex;
try {
changedLineIndex = await loadChangedLineIndex(github, context, pr);
} catch (err) {
core.warning(`semantic review PR files were not listed: ${err.message}`);
for (const finding of candidates) {
const key = findingKey(finding, facts);
inlineState.set(key, { label: "summary-only; PR files were not listed" });
}
return inlineState;
}
const existing = await loadExistingInlineThreads(github, context, core, pr);
for (const finding of candidates) {
const key = findingKey(finding, facts);
const current = existing.get(key);
if (current && !current.resolved) {
const inlineTarget = current.path && current.line > 0
? { path: current.path, line: current.line }
: selectInlineTarget(finding, facts, changedLineIndex);
const nextBody = inlineTarget ? inlineCommentBody(finding, facts, inlineTarget) : "";
if (!current.resolved && current.commentId > 0 && nextBody && current.body !== nextBody) {
try {
if (!(await publishTargetStillCurrent(github, context, core, publishTarget, "inline comment"))) {
return inlineState;
}
await github.rest.pulls.updateReviewComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: current.commentId,
body: nextBody,
});
current.body = nextBody;
current.label = current.resolutionKnown
? `updated existing unresolved discussion${current.location || ""}`
: `updated existing discussion with unknown resolution${current.location || ""}`;
} catch (err) {
core.warning(`inline semantic review comment was not updated: ${err.message}`);
current.label = `${current.label}; update failed`;
current.failed = true;
}
}
inlineState.set(key, { label: current.label, resolved: current.resolved, failed: !!current.failed });
continue;
}
const inlineTarget = selectInlineTarget(finding, facts, changedLineIndex);
if (!inlineTarget) {
const state = { label: "summary-only; no stable changed diff line" };
inlineState.set(key, state);
existing.set(key, state);
continue;
}
try {
if (!(await publishTargetStillCurrent(github, context, core, publishTarget, "inline comment"))) {
return inlineState;
}
await github.rest.pulls.createReviewComment({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr,
commit_id: headSha,
path: inlineTarget.path,
line: inlineTarget.line,
side: "RIGHT",
body: inlineCommentBody(finding, facts, inlineTarget),
});
const state = { label: `posted to ${inlineCode(`${inlineTarget.path}:${inlineTarget.line}`)}` };
inlineState.set(key, state);
existing.set(key, state);
} catch (err) {
core.warning(`inline semantic review comment was not published: ${err.message}`);
const state = { label: "inline comment failed; see workflow warning", failed: true };
inlineState.set(key, state);
existing.set(key, state);
}
}
return inlineState;
}
function verifiedPublishTarget() {
const pr = Number(process.env.SEMANTIC_REVIEW_PR_NUMBER || 0);
if (!Number.isInteger(pr) || pr <= 0) {
throw new Error("missing verified semantic review pull request number");
}
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
if (!/^[a-f0-9]{40}$/i.test(headSha)) {
throw new Error("missing verified semantic review head sha");
}
const baseSha = process.env.SEMANTIC_REVIEW_BASE_SHA || "";
if (!/^[a-f0-9]{40}$/i.test(baseSha)) {
throw new Error("missing verified semantic review base sha");
}
const runId = process.env.SEMANTIC_REVIEW_RUN_ID || "";
if (runId && !/^\d+$/.test(runId)) {
throw new Error("invalid verified semantic review run id");
}
return { pr, headSha, baseSha, runId };
}
async function publishTargetStillCurrent(github, context, core, target, phase = "publishing") {
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: target.pr,
});
if (pr.state !== "open") {
core.notice(`semantic review skipped: PR is no longer open before ${phase}`);
return false;
}
if (pr.head.sha !== target.headSha) {
core.notice(`semantic review skipped: PR head changed before ${phase}`);
return false;
}
if (pr.base.sha !== target.baseSha) {
core.notice(`semantic review skipped: PR base changed before ${phase}`);
return false;
}
if (pr.base.repo.id !== context.payload.repository.id) {
throw new Error("PR base repo mismatch before publishing");
}
return true;
}
async function publish({ github, context, core }) {
const run = context.payload.workflow_run;
if (!run || run.event !== "pull_request" || run.conclusion !== "success") {
core.notice("semantic review skipped: workflow_run is not a successful pull_request run");
return;
}
const runtimeBlockMode = parseBlockMode(process.env.SEMANTIC_REVIEW_BLOCK || "");
const target = verifiedPublishTarget();
if (!(await publishTargetStillCurrent(github, context, core, target))) {
return;
}
const { pr, headSha } = target;
const decision = publishableDecision(loadDecision(), runtimeBlockMode);
const facts = loadFacts();
const inlineState = await publishInlineComments({ github, context, core, target, pr, headSha, decision, facts, runtimeBlockMode });
const conclusion = checkConclusion(decision, runtimeBlockMode);
const summaryRequired = semanticSummaryRequired(decision, runtimeBlockMode);
const inlineFailures = inlineFailureCount(inlineState);
let checkConclusionValue = conclusion;
let summaryPublicationError = "";
let checkRunId = 0;
if (!(await publishTargetStillCurrent(github, context, core, target, "check creation"))) {
return;
}
const check = await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: checkName(runtimeBlockMode),
head_sha: headSha,
status: "completed",
conclusion: checkConclusionValue,
output: {
title: buildCheckTitle(decision, checkConclusionValue, runtimeBlockMode),
summary: buildCheckSummary(decision, checkConclusionValue, {
summaryRequired,
inlineFailureCount: inlineFailures,
}).slice(0, 65000),
},
});
checkRunId = Number(check?.data?.id || 0);
try {
if (summaryRequired) {
const body = buildSummaryMarkdown(decision, facts, inlineState);
if (!(await publishTargetStillCurrent(github, context, core, target, "summary comment"))) {
return;
}
await publishQualitySummary({
github,
context,
pr,
target,
markdown: body,
beforeWrite: (action) => publishTargetStillCurrent(github, context, core, target, `summary comment ${action}`),
});
} else {
if (!(await publishTargetStillCurrent(github, context, core, target, "summary comment cleanup"))) {
return;
}
await deleteQualitySummaries({
github,
context,
pr,
target,
beforeWrite: (action) => publishTargetStillCurrent(github, context, core, target, `summary comment ${action}`),
});
}
} catch (err) {
summaryPublicationError = err.message;
core.warning(`semantic review summary comment was not published or cleaned up: ${summaryPublicationError}`);
if (checkRunId > 0) {
checkConclusionValue = "failure";
await github.rest.checks.update({
owner: context.repo.owner,
repo: context.repo.repo,
check_run_id: checkRunId,
conclusion: checkConclusionValue,
output: {
title: buildCheckTitle(decision, checkConclusionValue, runtimeBlockMode, { summaryPublicationError }),
summary: buildCheckSummary(decision, checkConclusionValue, {
summaryRequired,
summaryPublicationError,
inlineFailureCount: inlineFailures,
}).slice(0, 65000),
},
});
}
}
}
module.exports = {
buildCheckSummary,
buildSummaryMarkdown,
buildChangedLineIndex,
buildCheckTitle,
checkConclusion,
checkName,
changedLinesFromPatch,
evidenceLocation,
findingKey,
inlineCode,
inlineCommentBody,
loadDecision,
loadExistingInlineThreads,
loadFacts,
parseBlockMode,
publish,
publishInlineComments,
resolveFindingEvidence,
sanitizeMarkdownBody,
selectInlineTarget,
semanticSummaryRequired,
verifiedPublishTarget,
};
File diff suppressed because it is too large Load Diff
+552
View File
@@ -0,0 +1,552 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("fs");
const crypto = require("crypto");
const zlib = require("zlib");
const MAX_FACTS_BYTES = 4 * 1024 * 1024;
const MAX_COMPRESSION_RATIO = 100;
const MAX_ARRAY_ITEMS = 5000;
const MAX_STRING_BYTES = 8192;
const VALID_ACTIONS = new Set(["REJECT", "LABEL", "WARNING"]);
const MAX_OBJECT_KEYS = 1000;
const MAX_JSON_DEPTH = 12;
function isSymlink(entry) {
return ((entry.externalFileAttributes >>> 16) & 0o170000) === 0o120000;
}
function isRegularOrUnspecified(entry) {
const fileType = (entry.externalFileAttributes >>> 16) & 0o170000;
return fileType === 0 || fileType === 0o100000;
}
function verifyZipEntries(entries) {
if (entries.length !== 1) {
throw new Error(`expected exactly one artifact file, got ${entries.length}`);
}
const entry = entries[0];
if (entry.fileName !== "facts.json" || entry.fileName.startsWith("/") || entry.fileName.includes("..")) {
throw new Error(`invalid artifact path: ${entry.fileName}`);
}
if (isSymlink(entry)) {
throw new Error("facts artifact must not contain symlinks");
}
if (!isRegularOrUnspecified(entry)) {
throw new Error("facts artifact must contain a regular file");
}
if (entry.uncompressedSize <= 0 || entry.uncompressedSize > MAX_FACTS_BYTES) {
throw new Error(`invalid facts size: ${entry.uncompressedSize}`);
}
if (entry.compressedSize > 0 && entry.uncompressedSize / entry.compressedSize > MAX_COMPRESSION_RATIO) {
throw new Error("facts artifact compression ratio is too high");
}
return entry;
}
function readZipEntries(zipPath) {
return readZipEntriesFromBuffer(fs.readFileSync(zipPath));
}
function readZipEntriesFromBuffer(buf) {
const eocdOffset = findEndOfCentralDirectory(buf);
requireBufferRange(buf, eocdOffset, 22, "zip end of central directory");
const entriesTotal = buf.readUInt16LE(eocdOffset + 10);
const centralDirectorySize = buf.readUInt32LE(eocdOffset + 12);
const centralDirectoryOffset = buf.readUInt32LE(eocdOffset + 16);
requireBufferRange(buf, centralDirectoryOffset, centralDirectorySize, "zip central directory");
if (centralDirectoryOffset + centralDirectorySize > eocdOffset) {
throw new Error("zip central directory overlaps end of central directory");
}
const entries = [];
let offset = centralDirectoryOffset;
for (let i = 0; i < entriesTotal; i++) {
requireBufferRange(buf, offset, 46, "zip central directory entry");
if (buf.readUInt32LE(offset) !== 0x02014b50) {
throw new Error("invalid zip central directory");
}
const compressionMethod = buf.readUInt16LE(offset + 10);
const compressedSize = buf.readUInt32LE(offset + 20);
const uncompressedSize = buf.readUInt32LE(offset + 24);
const fileNameLength = buf.readUInt16LE(offset + 28);
const extraLength = buf.readUInt16LE(offset + 30);
const commentLength = buf.readUInt16LE(offset + 32);
const externalFileAttributes = buf.readUInt32LE(offset + 38);
const localHeaderOffset = buf.readUInt32LE(offset + 42);
const fileNameStart = offset + 46;
requireBufferRange(buf, fileNameStart, fileNameLength + extraLength + commentLength, "zip central directory name");
const fileName = buf.toString("utf8", fileNameStart, fileNameStart + fileNameLength);
entries.push({
fileName,
externalFileAttributes,
uncompressedSize,
compressedSize,
compressionMethod,
localHeaderOffset,
});
offset = fileNameStart + fileNameLength + extraLength + commentLength;
}
if (offset > centralDirectoryOffset + centralDirectorySize) {
throw new Error("zip central directory entry exceeds declared size");
}
return entries;
}
function findEndOfCentralDirectory(buf) {
if (buf.length < 22) {
throw new Error("zip end of central directory not found");
}
const minOffset = Math.max(0, buf.length - 0xffff - 22);
for (let offset = buf.length - 22; offset >= minOffset; offset--) {
if (buf.readUInt32LE(offset) === 0x06054b50) {
return offset;
}
}
throw new Error("zip end of central directory not found");
}
function requireBufferRange(buf, offset, length, label) {
if (!Number.isInteger(offset) || !Number.isInteger(length) || offset < 0 || length < 0 || offset + length > buf.length) {
throw new Error(`${label} is outside artifact bounds`);
}
}
function extractEntryFromBuffer(buf, entry) {
const offset = entry.localHeaderOffset;
requireBufferRange(buf, offset, 30, "zip local file header");
if (buf.readUInt32LE(offset) !== 0x04034b50) {
throw new Error("invalid zip local file header");
}
const compressionMethod = buf.readUInt16LE(offset + 8);
const fileNameLength = buf.readUInt16LE(offset + 26);
const extraLength = buf.readUInt16LE(offset + 28);
const dataStart = offset + 30 + fileNameLength + extraLength;
requireBufferRange(buf, offset + 30, fileNameLength + extraLength, "zip local file name");
requireBufferRange(buf, dataStart, entry.compressedSize, "zip local file data");
const compressed = buf.subarray(dataStart, dataStart + entry.compressedSize);
let out;
if (compressionMethod === 0) {
out = Buffer.from(compressed);
} else if (compressionMethod === 8) {
out = zlib.inflateRawSync(compressed, { maxOutputLength: MAX_FACTS_BYTES });
} else {
throw new Error(`unsupported zip compression method: ${compressionMethod}`);
}
if (out.length !== entry.uncompressedSize) {
throw new Error(`facts size mismatch: ${out.length} != ${entry.uncompressedSize}`);
}
return out;
}
function verifyArtifactDigest(buf, expectedDigest) {
if (!expectedDigest) {
throw new Error("artifact digest is required");
}
const match = /^sha256:([a-f0-9]{64})$/i.exec(expectedDigest);
if (!match) {
throw new Error(`unsupported artifact digest: ${expectedDigest}`);
}
const got = crypto.createHash("sha256").update(buf).digest("hex");
if (got.toLowerCase() !== match[1].toLowerCase()) {
throw new Error("facts artifact digest mismatch");
}
}
function requireArray(facts, key) {
if (!(key in facts)) {
return [];
}
if (!Array.isArray(facts[key])) {
throw new Error(`facts JSON ${key} must be an array`);
}
if (facts[key].length > MAX_ARRAY_ITEMS) {
throw new Error(`facts JSON ${key} has too many items`);
}
return facts[key];
}
function requireObject(value, path) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`facts JSON ${path} must be an object`);
}
return value;
}
function requireString(value, path, { optional = false } = {}) {
if (value === undefined || value === null) {
if (optional) {
return "";
}
throw new Error(`facts JSON ${path} must be a string`);
}
if (typeof value !== "string") {
throw new Error(`facts JSON ${path} must be a string`);
}
if (Buffer.byteLength(value, "utf8") > MAX_STRING_BYTES) {
throw new Error(`facts JSON ${path} is too long`);
}
return value;
}
function requireBoolean(value, path, { optional = false } = {}) {
if (value === undefined || value === null) {
if (optional) {
return false;
}
throw new Error(`facts JSON ${path} must be a boolean`);
}
if (typeof value !== "boolean") {
throw new Error(`facts JSON ${path} must be a boolean`);
}
return value;
}
function requireInteger(value, path, { optional = false, min = 0, max = 1000000 } = {}) {
if (value === undefined || value === null) {
if (optional) {
return 0;
}
throw new Error(`facts JSON ${path} must be an integer`);
}
if (!Number.isInteger(value) || value < min || value > max) {
throw new Error(`facts JSON ${path} must be an integer between ${min} and ${max}`);
}
return value;
}
function requireLine(value, path) {
if (!Number.isInteger(value) || value < 0 || value > 1000000) {
throw new Error(`facts JSON ${path} must be a valid line number`);
}
}
function requireSafePath(value, path) {
const file = requireString(value, path);
if (file.startsWith("/") || file.includes("..") || file.includes("\0")) {
throw new Error(`facts JSON ${path} must be a repository-relative path`);
}
return file;
}
function requirePublicContentFile(value, path) {
const file = requireString(value, path);
if (file === "branch" || file === "pull_request_metadata" || /^commit:[0-9a-f]{7,40}$/.test(file)) {
return file;
}
if (file.startsWith("commit:")) {
throw new Error(`facts JSON ${path} must be a valid public content location`);
}
requireSafePath(file, path);
if (
file === "" ||
file === "." ||
file.startsWith("./") ||
file.includes("\\") ||
file.includes("\0") ||
file.split("/").includes(".git") ||
/^[A-Za-z][A-Za-z0-9+.-]*:/.test(file)
) {
throw new Error(`facts JSON ${path} must be a repository-relative path`);
}
return file;
}
function requirePositiveLine(value, path) {
requireLine(value, path);
if (value === 0) {
throw new Error(`facts JSON ${path} must be a positive line number`);
}
}
function requireStringArray(value, path, { optional = false } = {}) {
if (value === undefined || value === null) {
if (optional) {
return [];
}
throw new Error(`facts JSON ${path} must be an array`);
}
if (!Array.isArray(value)) {
throw new Error(`facts JSON ${path} must be an array`);
}
if (value.length > MAX_ARRAY_ITEMS) {
throw new Error(`facts JSON ${path} has too many items`);
}
for (const [i, item] of value.entries()) {
requireString(item, `${path}[${i}]`);
}
return value;
}
function requireJSONValue(value, path, depth = 0) {
if (depth > MAX_JSON_DEPTH) {
throw new Error(`facts JSON ${path} is too deeply nested`);
}
if (value === null || typeof value === "boolean" || typeof value === "number") {
return;
}
if (typeof value === "string") {
requireString(value, path);
return;
}
if (Array.isArray(value)) {
if (value.length > MAX_ARRAY_ITEMS) {
throw new Error(`facts JSON ${path} has too many items`);
}
for (const [i, item] of value.entries()) {
requireJSONValue(item, `${path}[${i}]`, depth + 1);
}
return;
}
if (typeof value === "object") {
const entries = Object.entries(value);
if (entries.length > MAX_OBJECT_KEYS) {
throw new Error(`facts JSON ${path} has too many keys`);
}
for (const [key, item] of entries) {
requireString(key, `${path} key`);
requireJSONValue(item, `${path}.${key}`, depth + 1);
}
return;
}
throw new Error(`facts JSON ${path} must be a JSON value`);
}
function requireStringArrayMap(value, path, { optional = false } = {}) {
if (value === undefined || value === null) {
if (optional) {
return;
}
throw new Error(`facts JSON ${path} must be an object`);
}
const obj = requireObject(value, path);
const entries = Object.entries(obj);
if (entries.length > MAX_OBJECT_KEYS) {
throw new Error(`facts JSON ${path} has too many keys`);
}
for (const [key, values] of entries) {
requireString(key, `${path} key`);
requireStringArray(values, `${path}.${key}`);
}
}
function verifyDryRunRequest(value, path) {
if (value === undefined || value === null) {
return;
}
const item = requireObject(value, path);
requireString(item.method, `${path}.method`);
requireString(item.url, `${path}.url`);
requireStringArrayMap(item.query, `${path}.query`, { optional: true });
if (item.params !== undefined && item.params !== null) {
requireObject(item.params, `${path}.params`);
requireJSONValue(item.params, `${path}.params`);
}
if (item.body !== undefined && item.body !== null) {
requireJSONValue(item.body, `${path}.body`);
}
}
function verifyCommandExample(value, path) {
const item = requireObject(value, path);
requireString(item.raw, `${path}.raw`);
requireSafePath(item.source_file, `${path}.source_file`);
requireLine(item.line, `${path}.line`);
requireString(item.command_path, `${path}.command_path`, { optional: true });
requireString(item.domain, `${path}.domain`, { optional: true });
requireString(item.source, `${path}.source`, { optional: true });
requireBoolean(item.changed, `${path}.changed`, { optional: true });
requireBoolean(item.executable, `${path}.executable`, { optional: true });
requireString(item.skip_reason, `${path}.skip_reason`, { optional: true });
requireInteger(item.exit_code, `${path}.exit_code`, { optional: true, min: 0, max: 255 });
requireInteger(item.stdout_bytes, `${path}.stdout_bytes`, { optional: true });
requireInteger(item.api_call_count, `${path}.api_call_count`, { optional: true });
verifyDryRunRequest(item.expected_request, `${path}.expected_request`);
verifyDryRunRequest(item.dry_run, `${path}.dry_run`);
}
function verifyFactsJSON(data) {
let facts;
try {
facts = JSON.parse(data.toString("utf8"));
} catch (err) {
throw new Error(`facts JSON is invalid: ${err.message}`);
}
if (!facts || typeof facts !== "object" || Array.isArray(facts)) {
throw new Error("facts JSON must be an object");
}
if (facts.schema_version !== 1) {
throw new Error("facts JSON schema_version must be 1");
}
for (const [i, value] of requireArray(facts, "commands").entries()) {
const item = requireObject(value, `commands[${i}]`);
requireString(item.path, `commands[${i}].path`);
requireString(item.canonical_path, `commands[${i}].canonical_path`, { optional: true });
requireString(item.domain, `commands[${i}].domain`, { optional: true });
requireBoolean(item.changed, `commands[${i}].changed`, { optional: true });
requireString(item.source, `commands[${i}].source`);
requireBoolean(item.generated, `commands[${i}].generated`, { optional: true });
requireStringArray(item.flags, `commands[${i}].flags`, { optional: true });
for (const [j, example] of requireArray(item, "examples").entries()) {
verifyCommandExample(example, `commands[${i}].examples[${j}]`);
}
requireBoolean(item.legacy_naming, `commands[${i}].legacy_naming`, { optional: true });
requireBoolean(item.name_conflicts_existing, `commands[${i}].name_conflicts_existing`, { optional: true });
requireBoolean(item.flag_alias_conflict, `commands[${i}].flag_alias_conflict`, { optional: true });
}
for (const [i, value] of requireArray(facts, "skills").entries()) {
const item = requireObject(value, `skills[${i}]`);
requireSafePath(item.source_file, `skills[${i}].source_file`);
requireLine(item.line, `skills[${i}].line`);
requireString(item.raw, `skills[${i}].raw`);
requireString(item.command_path, `skills[${i}].command_path`, { optional: true });
requireString(item.domain, `skills[${i}].domain`, { optional: true });
requireBoolean(item.changed, `skills[${i}].changed`, { optional: true });
requireString(item.source, `skills[${i}].source`, { optional: true });
requireBoolean(item.references_invalid_command, `skills[${i}].references_invalid_command`, { optional: true });
requireBoolean(item.destructive_without_guard, `skills[${i}].destructive_without_guard`, { optional: true });
requireBoolean(item.scope_conflict, `skills[${i}].scope_conflict`, { optional: true });
}
for (const [i, value] of requireArray(facts, "skill_quality").entries()) {
const item = requireObject(value, `skill_quality[${i}]`);
requireSafePath(item.source_file, `skill_quality[${i}].source_file`);
requireString(item.domain, `skill_quality[${i}].domain`, { optional: true });
requireBoolean(item.changed, `skill_quality[${i}].changed`, { optional: true });
requireInteger(item.word_count, `skill_quality[${i}].word_count`, { optional: true });
requireInteger(item.critical_count, `skill_quality[${i}].critical_count`, { optional: true });
requireInteger(item.description_length, `skill_quality[${i}].description_length`, { optional: true });
requireBoolean(item.critical_over_budget, `skill_quality[${i}].critical_over_budget`, { optional: true });
}
for (const [i, value] of requireArray(facts, "errors").entries()) {
const item = requireObject(value, `errors[${i}]`);
requireSafePath(item.file, `errors[${i}].file`);
requireLine(item.line, `errors[${i}].line`);
requireString(item.command, `errors[${i}].command`, { optional: true });
requireString(item.command_path, `errors[${i}].command_path`, { optional: true });
requireString(item.domain, `errors[${i}].domain`, { optional: true });
requireBoolean(item.changed, `errors[${i}].changed`, { optional: true });
requireString(item.source, `errors[${i}].source`, { optional: true });
requireBoolean(item.boundary, `errors[${i}].boundary`, { optional: true });
requireBoolean(item.uses_structured_error, `errors[${i}].uses_structured_error`, { optional: true });
requireBoolean(item.has_hint, `errors[${i}].has_hint`, { optional: true });
requireInteger(item.hint_action_count, `errors[${i}].hint_action_count`, { optional: true });
requireBoolean(item.required_hint, `errors[${i}].required_hint`, { optional: true });
requireString(item.code, `errors[${i}].code`, { optional: true });
requireString(item.message, `errors[${i}].message`, { optional: true });
requireString(item.hint, `errors[${i}].hint`, { optional: true });
requireBoolean(item.retryable, `errors[${i}].retryable`, { optional: true });
}
for (const [i, value] of requireArray(facts, "outputs").entries()) {
const item = requireObject(value, `outputs[${i}]`);
requireString(item.command, `outputs[${i}].command`);
requireString(item.domain, `outputs[${i}].domain`, { optional: true });
requireBoolean(item.changed, `outputs[${i}].changed`, { optional: true });
requireString(item.source, `outputs[${i}].source`, { optional: true });
requireStringArray(item.fields, `outputs[${i}].fields`, { optional: true });
requireBoolean(item.is_list, `outputs[${i}].is_list`, { optional: true });
requireBoolean(item.has_default_limit, `outputs[${i}].has_default_limit`, { optional: true });
requireBoolean(item.has_field_selector, `outputs[${i}].has_field_selector`, { optional: true });
requireBoolean(item.has_decision_field, `outputs[${i}].has_decision_field`, { optional: true });
}
for (const [i, value] of requireArray(facts, "examples").entries()) {
verifyCommandExample(value, `examples[${i}]`);
}
for (const [i, value] of requireArray(facts, "public_content").entries()) {
const item = requireObject(value, `public_content[${i}]`);
requireString(item.rule, `public_content[${i}].rule`);
const action = requireString(item.action, `public_content[${i}].action`);
if (!VALID_ACTIONS.has(action)) {
throw new Error(`facts JSON public_content[${i}].action is invalid`);
}
requirePublicContentFile(item.file, `public_content[${i}].file`);
requirePositiveLine(item.line, `public_content[${i}].line`);
requireString(item.source, `public_content[${i}].source`, { optional: true });
requireString(item.excerpt, `public_content[${i}].excerpt`, { optional: true });
requireString(item.message, `public_content[${i}].message`, { optional: true });
requireString(item.suggestion, `public_content[${i}].suggestion`, { optional: true });
}
for (const [i, value] of requireArray(facts, "diagnostics").entries()) {
const item = requireObject(value, `diagnostics[${i}]`);
requireString(item.rule, `diagnostics[${i}].rule`);
const action = requireString(item.action, `diagnostics[${i}].action`);
if (!VALID_ACTIONS.has(action)) {
throw new Error(`facts JSON diagnostics[${i}].action is invalid`);
}
requireSafePath(item.file, `diagnostics[${i}].file`);
requireLine(item.line, `diagnostics[${i}].line`);
requireString(item.message, `diagnostics[${i}].message`);
requireString(item.suggestion, `diagnostics[${i}].suggestion`, { optional: true });
requireString(item.subject_type, `diagnostics[${i}].subject_type`, { optional: true });
requireString(item.command_path, `diagnostics[${i}].command_path`, { optional: true });
requireString(item.flag_name, `diagnostics[${i}].flag_name`, { optional: true });
}
}
function writeVerifiedFacts(zipPath, outPath, expectedDigest = "") {
const buf = fs.readFileSync(zipPath);
verifyArtifactDigest(buf, expectedDigest);
const entry = verifyZipEntries(readZipEntriesFromBuffer(buf));
const data = extractEntryFromBuffer(buf, entry);
verifyFactsJSON(data);
fs.writeFileSync(outPath, data);
return entry;
}
function verificationFailureDecision(message, blockMode) {
return {
block_mode: blockMode,
degraded: true,
infrastructure_failure: true,
system_warnings: [{
severity: "critical",
message: `quality-gate facts artifact verification failed: ${message}`,
suggested_action: "inspect the semantic-review workflow artifact verification logs and rerun CI after the artifact issue is resolved",
}],
blockers: [],
warnings: [],
};
}
function writeFailureDecisionFromEnv(err) {
const decisionOut = process.env.SEMANTIC_REVIEW_DECISION_OUT || "";
if (!decisionOut) {
return;
}
const blockMode = process.env.SEMANTIC_REVIEW_BLOCK === "true";
const message = err && err.message ? err.message : String(err || "unknown error");
const decision = verificationFailureDecision(message, blockMode);
fs.writeFileSync(decisionOut, JSON.stringify(decision, null, 2) + "\n", "utf8");
const markdownOut = process.env.SEMANTIC_REVIEW_MARKDOWN_OUT || "";
if (markdownOut) {
fs.writeFileSync(markdownOut, [
"## Semantic Review",
"",
"The semantic review system could not produce a fully trusted result.",
"",
`- ${decision.system_warnings[0].message}`,
`- Action: ${decision.system_warnings[0].suggested_action}`,
"",
].join("\n"), "utf8");
}
}
if (require.main === module) {
const [zipPath, outPath = "facts.json", expectedDigest = ""] = process.argv.slice(2);
if (!zipPath) {
console.error("usage: node scripts/semantic-review-verify-artifact.js <artifact.zip> [facts.json] [sha256:<digest>]");
process.exit(2);
}
try {
writeVerifiedFacts(zipPath, outPath, expectedDigest);
} catch (err) {
console.error(`semantic-review artifact verifier: ${err.message}`);
try {
writeFailureDecisionFromEnv(err);
} catch (writeErr) {
console.error(`semantic-review artifact verifier: failed to write infrastructure decision: ${writeErr.message}`);
}
process.exit(1);
}
}
module.exports = { MAX_FACTS_BYTES, verifyArtifactDigest, verifyZipEntries, verifyFactsJSON, readZipEntries, extractEntryFromBuffer, writeVerifiedFacts, verificationFailureDecision };
@@ -0,0 +1,267 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const childProcess = require("node:child_process");
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const zlib = require("node:zlib");
const { MAX_FACTS_BYTES, extractEntryFromBuffer, verifyArtifactDigest, verifyZipEntries, writeVerifiedFacts } = require("./semantic-review-verify-artifact.js");
describe("verifyZipEntries", () => {
it("rejects path traversal and symlink entries", () => {
const badEntries = [
{ fileName: "../facts.json", externalFileAttributes: 0, compressedSize: 10, uncompressedSize: 10 },
{ fileName: "facts.json", externalFileAttributes: 0o120000 << 16, compressedSize: 10, uncompressedSize: 10 },
{ fileName: "facts.json", externalFileAttributes: 0o040000 << 16, compressedSize: 10, uncompressedSize: 10 },
];
for (const entry of badEntries) {
assert.throws(() => verifyZipEntries([entry]));
}
});
it("rejects multi-file and oversized artifacts", () => {
const entry = { fileName: "facts.json", externalFileAttributes: 0o100644 << 16, compressedSize: 100, uncompressedSize: 100 };
assert.throws(() => verifyZipEntries([entry, entry]));
assert.throws(() => verifyZipEntries([{ ...entry, uncompressedSize: MAX_FACTS_BYTES + 1 }]));
});
it("rejects suspicious compression ratios", () => {
const entry = { fileName: "facts.json", externalFileAttributes: 0o100644 << 16, compressedSize: 1, uncompressedSize: 1000 };
assert.throws(() => verifyZipEntries([entry]), /compression ratio/);
});
it("accepts exactly one regular facts file", () => {
const entry = { fileName: "facts.json", externalFileAttributes: 0o100644 << 16, compressedSize: 100, uncompressedSize: 100 };
assert.equal(verifyZipEntries([entry]), entry);
});
it("validates artifact sha256 digest when provided", () => {
const buf = Buffer.from("artifact");
const digest = crypto.createHash("sha256").update(buf).digest("hex");
assert.throws(() => verifyArtifactDigest(buf, ""), /artifact digest is required/);
assert.doesNotThrow(() => verifyArtifactDigest(buf, `sha256:${digest}`));
assert.throws(() => verifyArtifactDigest(buf, `sha256:${"0".repeat(64)}`), /digest mismatch/);
assert.throws(() => verifyArtifactDigest(buf, "md5:bad"), /unsupported artifact digest/);
});
it("caps deflated facts extraction before zip size mismatch checks", () => {
const header = Buffer.alloc(30);
header.writeUInt32LE(0x04034b50, 0);
header.writeUInt16LE(8, 8);
const compressed = zlib.deflateRawSync(Buffer.alloc(MAX_FACTS_BYTES + 1, "x"));
const entry = {
localHeaderOffset: 0,
compressedSize: compressed.length,
uncompressedSize: MAX_FACTS_BYTES,
};
assert.throws(() => extractEntryFromBuffer(Buffer.concat([header, compressed]), entry));
});
it("extracts facts from a real zip buffer", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "semantic-review-zip-"));
const zipPath = path.join(dir, "facts.zip");
const outPath = path.join(dir, "facts.json");
const restrictedScope = "pri" + "vate";
const facts = Buffer.from(JSON.stringify({
schema_version: 1,
public_content: [
{
rule: "public_content_semantic_candidate",
action: "WARNING",
file: "pull_request_metadata",
line: 1,
source: "metadata",
excerpt: "public release notes mention an internal rollout plan",
message: "public contribution may contain sensitive implementation detail",
suggestion: "move internal detail to " + restrictedScope + " discussion",
},
{
rule: "public_content_change_id_trailer",
action: "REJECT",
file: "commit:1234abc",
line: 3,
source: "commit",
},
{
rule: "public_content_automation_branch",
action: "WARNING",
file: "branch",
line: 1,
source: "branch",
},
{
rule: "public_content_" + "pri" + "vate_ipv4",
action: "WARNING",
file: "docs/public-network.md",
line: 7,
source: "file",
},
],
}) + "\n");
const zip = makeZip([{ fileName: "facts.json", data: facts, mode: 0o100644 }]);
fs.writeFileSync(zipPath, zip);
writeVerifiedFacts(zipPath, outPath, digestFor(zip));
assert.equal(fs.readFileSync(outPath, "utf8"), facts.toString("utf8"));
});
it("rejects malformed zip boundaries with a controlled error", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "semantic-review-zip-"));
const zipPath = path.join(dir, "facts.zip");
const outPath = path.join(dir, "facts.json");
const zip = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
fs.writeFileSync(zipPath, zip);
assert.throws(
() => writeVerifiedFacts(zipPath, outPath, digestFor(zip)),
/zip end of central directory|zip central directory|zip bounds/,
);
});
it("rejects invalid facts JSON shape", () => {
for (const [name, facts, want] of [
["not-json", Buffer.from("{"), /facts JSON is invalid/],
["array", Buffer.from("[]"), /facts JSON must be an object/],
["wrong-schema", Buffer.from('{"schema_version":2}'), /schema_version/],
["non-array-skills", Buffer.from('{"schema_version":1,"skills":{}}'), /skills must be an array/],
["bad-skill-path", Buffer.from('{"schema_version":1,"skills":[{"source_file":"../x","line":1,"raw":"x","references_invalid_command":true}]}'), /source_file/],
["bad-skill-line", Buffer.from('{"schema_version":1,"skills":[{"source_file":"skills/lark-doc/SKILL.md","line":"3","raw":"x","references_invalid_command":true}]}'), /line/],
["bad-command-item", Buffer.from('{"schema_version":1,"commands":["not-object"]}'), /commands\[0\]/],
["bad-command-flags", Buffer.from('{"schema_version":1,"commands":[{"path":"docs +fetch","source":"shortcut","flags":["ok",1]}]}'), /commands\[0\]\.flags\[1\]/],
["bad-skill-quality-path", Buffer.from('{"schema_version":1,"skill_quality":[{"source_file":"/tmp/SKILL.md","word_count":1,"critical_count":0,"description_length":10}]}'), /skill_quality\[0\]\.source_file/],
["bad-error-path", Buffer.from('{"schema_version":1,"errors":[{"file":"../x.go","line":1,"boundary":true,"uses_structured_error":false,"has_hint":false,"hint_action_count":0,"required_hint":true,"retryable":false}]}'), /errors\[0\]\.file/],
["bad-example-dry-run", Buffer.from('{"schema_version":1,"examples":[{"raw":"lark-cli docs +fetch","source_file":"skills/lark-doc/SKILL.md","line":3,"executable":true,"dry_run":{"method":"GET","url":"/open-apis/docx","query":{"page_size":["20",1]}}}]}'), /examples\[0\]\.dry_run\.query\.page_size\[1\]/],
["bad-output-field", Buffer.from(JSON.stringify({ schema_version: 1, outputs: [{ command: "drive files list", fields: ["ok", "x".repeat(9000)] }] })), /outputs\[0\]\.fields\[1\]/],
["non-array-public-content", Buffer.from('{"schema_version":1,"public_content":{}}'), /public_content must be an array/],
["bad-public-content-item", Buffer.from('{"schema_version":1,"public_content":["not-object"]}'), /public_content\[0\]/],
["bad-public-content-action", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"BLOCK","file":"pull_request_metadata","line":1}]}'), /public_content\[0\]\.action/],
["bad-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"../x","line":1}]}'), /public_content\[0\]\.file/],
["dot-slash-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"./foo","line":1}]}'), /public_content\[0\]\.file/],
["empty-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"","line":1}]}'), /public_content\[0\]\.file/],
["dot-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":".","line":1}]}'), /public_content\[0\]\.file/],
["url-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"https://example.invalid/x","line":1}]}'), /public_content\[0\]\.file/],
["dotgit-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":".git/config","line":1}]}'), /public_content\[0\]\.file/],
["windows-public-content-path", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"C:\\\\tmp\\\\x","line":1}]}'), /public_content\[0\]\.file/],
["bad-public-content-commit-ref", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_change_id_trailer","action":"REJECT","file":"commit:notasha","line":1}]}'), /public_content\[0\]\.file/],
["bad-public-content-line", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"pull_request_metadata","line":"1"}]}'), /public_content\[0\]\.line/],
["zero-public-content-line", Buffer.from('{"schema_version":1,"public_content":[{"rule":"public_content_semantic_candidate","action":"WARNING","file":"pull_request_metadata","line":0}]}'), /public_content\[0\]\.line/],
["bad-diagnostic-action", Buffer.from('{"schema_version":1,"diagnostics":[{"rule":"r","action":"BLOCK","file":"x.go","line":1,"message":"m"}]}'), /diagnostics.*action/],
["long-message", Buffer.from(JSON.stringify({ schema_version: 1, diagnostics: [{ rule: "r", action: "REJECT", file: "x.go", line: 1, message: "x".repeat(9000) }] })), /too long/],
]) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `facts-shape-${name}-`));
const zipPath = path.join(dir, "facts.zip");
const outPath = path.join(dir, "facts.json");
const zip = makeZip([{ fileName: "facts.json", data: facts, mode: 0o100644 }]);
fs.writeFileSync(zipPath, zip);
assert.throws(() => writeVerifiedFacts(zipPath, outPath, digestFor(zip)), want);
}
});
it("rejects invalid entries through real zip parsing", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "semantic-review-zip-"));
for (const [name, zip] of [
["duplicate", makeZip([
{ fileName: "facts.json", data: Buffer.from("{}"), mode: 0o100644 },
{ fileName: "facts.json", data: Buffer.from("{}"), mode: 0o100644 },
])],
["path-traversal", makeZip([{ fileName: "../facts.json", data: Buffer.from("{}"), mode: 0o100644 }])],
["symlink", makeZip([{ fileName: "facts.json", data: Buffer.from("target"), mode: 0o120000 }])],
]) {
const zipPath = path.join(dir, `${name}.zip`);
fs.writeFileSync(zipPath, zip);
assert.throws(() => writeVerifiedFacts(zipPath, path.join(dir, `${name}.json`), digestFor(zip)), /artifact|path|symlink|regular/);
}
});
it("writes an infrastructure decision when CLI verification fails", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "semantic-review-zip-"));
const zipPath = path.join(dir, "facts.zip");
const outPath = path.join(dir, "facts.json");
const decisionPath = path.join(dir, "decision.json");
const zip = makeZip([{ fileName: "../facts.json", data: Buffer.from("{}"), mode: 0o100644 }]);
fs.writeFileSync(zipPath, zip);
const result = childProcess.spawnSync(process.execPath, [path.join(__dirname, "semantic-review-verify-artifact.js"), zipPath, outPath, digestFor(zip)], {
env: {
...process.env,
SEMANTIC_REVIEW_BLOCK: "true",
SEMANTIC_REVIEW_DECISION_OUT: decisionPath,
},
encoding: "utf8",
});
assert.equal(result.status, 1);
assert.match(result.stderr, /invalid artifact path/);
const decision = JSON.parse(fs.readFileSync(decisionPath, "utf8"));
assert.equal(decision.block_mode, true);
assert.equal(decision.infrastructure_failure, true);
assert.match(decision.system_warnings[0].message, /invalid artifact path/);
});
});
function digestFor(buf) {
const digest = crypto.createHash("sha256").update(buf).digest("hex");
return `sha256:${digest}`;
}
function makeZip(entries) {
const locals = [];
const centrals = [];
let offset = 0;
for (const entry of entries) {
const name = Buffer.from(entry.fileName);
const data = Buffer.from(entry.data);
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0, 6);
local.writeUInt16LE(0, 8);
local.writeUInt32LE(0, 10);
local.writeUInt32LE(0, 14);
local.writeUInt32LE(data.length, 18);
local.writeUInt32LE(data.length, 22);
local.writeUInt16LE(name.length, 26);
local.writeUInt16LE(0, 28);
locals.push(local, name, data);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(0x0314, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE(0, 8);
central.writeUInt16LE(0, 10);
central.writeUInt32LE(0, 12);
central.writeUInt32LE(0, 16);
central.writeUInt32LE(data.length, 20);
central.writeUInt32LE(data.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt16LE(0, 30);
central.writeUInt16LE(0, 32);
central.writeUInt16LE(0, 34);
central.writeUInt16LE(0, 36);
central.writeUInt32LE((entry.mode || 0o100644) * 0x10000, 38);
central.writeUInt32LE(offset, 42);
centrals.push(central, name);
offset += local.length + name.length + data.length;
}
const centralOffset = offset;
const centralDirectory = Buffer.concat(centrals);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(entries.length, 8);
eocd.writeUInt16LE(entries.length, 10);
eocd.writeUInt32LE(centralDirectory.length, 12);
eocd.writeUInt32LE(centralOffset, 16);
eocd.writeUInt16LE(0, 20);
return Buffer.concat([...locals, centralDirectory, eocd]);
}
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env bash
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
set -euo pipefail
workflow=".github/workflows/semantic-review.yml"
extract_step() {
local name="$1"
awk -v name="$name" '
$0 == " - name: " name { in_step = 1; print; next }
in_step && /^ - (name|uses):/ { exit }
in_step { print }
' "$workflow"
}
extract_job() {
local name="$1"
awk -v name="$name" '
$0 == " " name ":" { in_job = 1; print; next }
in_job && /^ [A-Za-z0-9_-]+:/ { exit }
in_job { print }
' "$workflow"
}
require_in_step() {
local step="$1"
local needle="$2"
local message="$3"
if ! awk -v needle="$needle" '
index($0, needle) && $0 !~ /^[[:space:]]*(#|\/\/)/ { found = 1 }
END { exit found ? 0 : 1 }
' <<<"$step"; then
echo "$message" >&2
exit 1
fi
}
require_unique_step() {
local name="$1"
local count
count="$(grep -Fc " - name: $name" "$workflow")"
if [ "$count" -ne 1 ]; then
echo "semantic-review workflow should contain exactly one step named '$name', got $count" >&2
exit 1
fi
}
for unique_step in \
"Verify summary facts artifact metadata" \
"Verify and extract summary facts artifact" \
"Verify semantic facts artifact metadata" \
"Verify and extract semantic facts artifact"; do
require_unique_step "$unique_step"
done
verify_step="$(extract_step "Verify workflow run and pull request")"
summary_verify_step="$(extract_step "Verify workflow run and pull request for summary")"
summary_job="$(extract_job "pr-quality-summary")"
summary_artifact_step="$(extract_step "Verify summary facts artifact metadata")"
artifact_step="$(extract_step "Verify semantic facts artifact metadata")"
waiver_step="$(extract_step "Download PR semantic waiver config")"
semantic_step="$(extract_step "Run semantic review")"
precheckout_step="$(extract_step "Publish pre-checkout semantic review failure")"
summary_publish_step="$(extract_step "Publish PR quality summary")"
publish_step="$(extract_step "Publish semantic review")"
summary_extract_facts_step="$(extract_step "Verify and extract summary facts artifact")"
extract_facts_step="$(extract_step "Verify and extract semantic facts artifact")"
workflow_permissions="$(awk '
/^permissions:/ { in_permissions = 1; print; next }
in_permissions && /^jobs:/ { exit }
in_permissions { print }
' "$workflow")"
for denied_permission in "checks: write" "pull-requests: write" "issues: write"; do
if grep -Fq "$denied_permission" <<<"$workflow_permissions"; then
echo "semantic-review workflow should not grant write permissions at the workflow level" >&2
exit 1
fi
done
if ! grep -q 'pull-requests: write' "$workflow"; then
echo "semantic-review should request pull request write permission for PR comments" >&2
exit 1
fi
if ! grep -Fq 'pull-requests: write' <<<"$summary_job"; then
echo "pr-quality-summary should request pull request write permission for PR summary comments" >&2
exit 1
fi
if grep -q 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' "$workflow"; then
echo "semantic-review should not use the Node.js 20 github-script action" >&2
exit 1
fi
if ! grep -q 'actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd' "$workflow"; then
echo "semantic-review should pin github-script v8" >&2
exit 1
fi
if ! awk '
function finish_checkout() {
if (!in_checkout) {
return;
}
checkouts++;
if (step !~ /ref: \$\{\{ steps\.pr\.outputs\.base_sha \}\}/) {
printf("semantic-review trusted checkout must use verified base_sha:\n%s\n", step) > "/dev/stderr";
bad = 1;
}
if (step !~ /persist-credentials: false/) {
printf("semantic-review trusted checkout must not persist credentials:\n%s\n", step) > "/dev/stderr";
bad = 1;
}
if (step ~ /(head_sha|head_ref|workflow_run\.head_sha|github\.head_ref)/) {
printf("semantic-review trusted checkout must not reference PR head inputs:\n%s\n", step) > "/dev/stderr";
bad = 1;
}
in_checkout = 0;
step = "";
}
/^ - (name|uses):/ {
finish_checkout();
}
/uses: actions\/checkout@/ {
in_checkout = 1;
step = $0 "\n";
next;
}
in_checkout {
step = step $0 "\n";
}
END {
finish_checkout();
if (checkouts < 2) {
printf("semantic-review should have at least two trusted checkout steps, got %d\n", checkouts) > "/dev/stderr";
bad = 1;
}
exit bad ? 1 : 0;
}
' "$workflow"; then
exit 1
fi
for forbidden in \
"manifest-export" \
"quality-gate manifest" \
"quality-gate command-index" \
"make quality-gate"; do
if grep -Fq "$forbidden" "$workflow"; then
echo "semantic-review trusted workflow must not contain: $forbidden" >&2
exit 1
fi
done
if ! grep -q '^ pr-quality-summary:' "$workflow"; then
echo "semantic-review workflow should publish a PR quality summary for CI workflow_run results" >&2
exit 1
fi
if ! grep -Fq "needs: pr-quality-summary" "$workflow"; then
echo "semantic-review job should wait for PR quality summary cleanup/publication" >&2
exit 1
fi
if grep -Fq "needs.pr-quality-summary.result == 'success'" "$workflow"; then
echo "semantic-review job should still run after PR quality summary publication fails" >&2
exit 1
fi
if ! grep -Fq "if: always() && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'" "$workflow"; then
echo "semantic-review job should use always() so its check still runs after PR quality summary failures" >&2
exit 1
fi
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
require_in_step "$summary_verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "PR quality summary should tolerate mutable workflow_run PR head metadata"
require_in_step "$summary_verify_step" 'factsArtifactPattern' "PR quality summary should use the base-bound facts artifact name when available"
require_in_step "$summary_verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "PR quality summary must prefer the CI-time artifact base SHA"
require_in_step "$summary_verify_step" 'core.setOutput("artifact_error"' "PR quality summary must expose artifact binding failures"
require_in_step "$summary_verify_step" 'state: "all"' "PR quality summary fallback must inspect closed PRs before failing"
require_in_step "$summary_verify_step" 'candidate.state === "open"' "PR quality summary fallback must still prefer open PRs"
require_in_step "$summary_verify_step" 'workflow_run target PR is no longer open' "PR quality summary must skip stale workflow_run events after PR closure"
require_in_step "$summary_verify_step" 'pr.state !== "open"' "PR quality summary must skip direct workflow_run PR bindings after PR closure"
require_in_step "$summary_artifact_step" 'factsArtifactName' "PR quality summary artifact step must use the verified facts artifact binding"
require_in_step "$summary_extract_facts_step" 'SEMANTIC_REVIEW_DECISION_OUT' "PR quality summary artifact verifier must write an infrastructure decision on verifier failure"
if grep -Fq 'run.conclusion !== "success"' <<<"$summary_verify_step"; then
echo "PR quality summary must run for failed pull_request CI runs, not only successful runs" >&2
exit 1
fi
require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_HEAD_SHA' "PR quality summary publisher must receive verified head SHA"
require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_BASE_SHA' "PR quality summary publisher must receive verified base SHA"
require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_RUN_ID' "PR quality summary publisher must receive verified workflow run id"
require_in_step "$summary_publish_step" 'require("./scripts/ci-quality-summary-publish.js")' "PR quality summary publisher must use the shared CI publisher script"
require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "semantic-review must verify the triggering workflow path"
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"
require_in_step "$verify_step" 'const eventHeadSha = runPRs[0]?.head?.sha || ""' "semantic-review should inspect workflow_run PR head metadata"
require_in_step "$verify_step" 'const targetHeadSha = run.head_sha' "semantic-review target PR head must come from the completed CI run"
require_in_step "$verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR head metadata"
require_in_step "$verify_step" 'factsArtifactPattern' "semantic-review must use a base-bound facts artifact name"
require_in_step "$verify_step" 'listWorkflowRunArtifacts' "semantic-review must read the workflow_run artifacts before resolving fallback base SHA"
require_in_step "$verify_step" 'artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review must not let the artifact choose a different PR head"
require_in_step "$verify_step" 'artifactError =' "semantic-review must preserve PR target outputs when artifact binding is unavailable"
require_in_step "$verify_step" 'runPRs.length > 1' "semantic-review must fail closed on ambiguous workflow_run PR bindings"
require_in_step "$verify_step" 'listPullRequestsAssociatedWithCommit' "semantic-review must resolve fork workflow_run PRs when pull_requests is empty"
require_in_step "$verify_step" 'commit_sha: targetHeadSha' "semantic-review fallback must resolve PRs by the workflow_run PR head SHA"
require_in_step "$verify_step" 'github.rest.pulls.list' "semantic-review must have a pull-list fallback when commit association is empty"
require_in_step "$verify_step" 'openCandidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
require_in_step "$verify_step" 'state: "all"' "semantic-review fallback must inspect closed PRs before failing"
require_in_step "$verify_step" 'candidate.state === "open"' "semantic-review fallback must still prefer open PRs"
require_in_step "$verify_step" 'workflow_run target PR is no longer open' "semantic-review must skip stale workflow_run events after PR closure"
require_in_step "$verify_step" 'pr.state !== "open"' "semantic-review must skip direct workflow_run PR bindings after PR closure"
require_in_step "$verify_step" '!pr.head.repo' "semantic-review must skip unavailable PR head repositories before reading owner/repo"
require_in_step "$verify_step" 'pr.head.sha !== targetHeadSha' "semantic-review must skip stale PR heads"
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR base metadata"
require_in_step "$verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "semantic-review must prefer the CI-time artifact base SHA"
require_in_step "$verify_step" 'pr.base.sha !== baseSha' "semantic-review must skip stale PR bases"
require_in_step "$verify_step" 'core.setOutput("run_id"' "semantic-review must pass verified workflow run id to publisher"
require_in_step "$verify_step" 'core.setOutput("head_repo_id"' "semantic-review must pass verified head repo id"
require_in_step "$verify_step" 'core.setOutput("head_is_base_repo"' "semantic-review must expose same-repo versus fork boundary"
require_in_step "$verify_step" 'core.setOutput("facts_artifact_name"' "semantic-review must pass the verified facts artifact binding"
require_in_step "$verify_step" 'core.setOutput("artifact_error"' "semantic-review must expose artifact binding failures for infrastructure reporting"
require_in_step "$artifact_step" 'factsArtifactName' "semantic-review artifact step must use the verified facts artifact binding"
require_in_step "$artifact_step" 'a.name === factsArtifactName' "semantic-review must select only the verified quality-gate-facts artifact"
require_in_step "$artifact_step" 'artifacts.length !== 1' "semantic-review must reject missing or duplicate facts artifacts"
require_in_step "$artifact_step" 'artifact.expired' "semantic-review must reject expired facts artifacts"
require_in_step "$artifact_step" 'artifact.size_in_bytes > 5 * 1024 * 1024' "semantic-review must cap facts artifact size"
require_in_step "$artifact_step" 'artifact.digest' "semantic-review must require the GitHub artifact digest"
require_in_step "$extract_facts_step" 'SEMANTIC_REVIEW_DECISION_OUT' "semantic-review artifact verifier must write an infrastructure decision on verifier failure"
require_in_step "$extract_facts_step" 'SEMANTIC_REVIEW_MARKDOWN_OUT' "semantic-review artifact verifier must write markdown on verifier failure"
require_in_step "$waiver_step" 'SEMANTIC_REVIEW_HEAD_IS_BASE_REPO' "waiver step must know whether PR head is in the base repo"
require_in_step "$waiver_step" 'fork PR semantic waiver config is ignored' "fork PR head waiver must be ignored"
require_in_step "$waiver_step" 'core.setOutput("path", "")' "fork PR must not pass an empty waiver override file"
require_in_step "$waiver_step" 'owner: headOwner' "same-repo waiver fetch must use the verified head owner"
require_in_step "$waiver_step" 'repo: headRepo' "same-repo waiver fetch must use the verified head repo"
require_in_step "$waiver_step" 'ref: headSha' "same-repo waiver fetch must use the verified head sha"
require_in_step "$waiver_step" 'data.size > 256 * 1024' "semantic-review should cap PR waiver config size before parsing"
if ! awk '
/Download PR semantic waiver config/ { in_step = 1 }
in_step && /const headIsBaseRepo/ { seen = 1 }
seen && /fork PR semantic waiver config is ignored/ { notice = 1 }
notice && /core\.setOutput\("path", ""\)/ { output = 1 }
output && /return;/ { returned = 1 }
in_step && /github\.rest\.repos\.getContent/ { if (!returned) exit 2 }
in_step && /^ - name:/ && !/Download PR semantic waiver config/ { exit }
END { exit returned ? 0 : 1 }
' "$workflow"; then
echo "fork PR waiver config must be ignored before any head repo content fetch" >&2
exit 1
fi
require_in_step "$semantic_step" 'if [ -n "${{ steps.waiver_config.outputs.path }}" ]; then' "semantic review must not pass an empty waivers-file override"
require_in_step "$semantic_step" 'args+=(--waivers-file' "same-repo PR head waiver path must still be passed when present"
require_in_step "$precheckout_step" 'SEMANTIC_REVIEW_BASE_SHA' "pre-checkout failure publisher must receive verified base SHA"
require_in_step "$precheckout_step" 'SEMANTIC_REVIEW_RUN_ID' "pre-checkout failure publisher must receive verified run id"
require_in_step "$precheckout_step" 'github.rest.pulls.get' "pre-checkout failure publisher must recheck PR target before writing"
require_in_step "$precheckout_step" 'pull.state !== "open"' "pre-checkout failure publisher must skip closed PRs before writing"
require_in_step "$precheckout_step" 'pull.head.sha !== headSha' "pre-checkout failure publisher must skip stale PR heads"
require_in_step "$precheckout_step" 'pull.base.sha !== baseSha' "pre-checkout failure publisher must skip stale PR bases"
require_in_step "$publish_step" 'SEMANTIC_REVIEW_HEAD_SHA' "semantic-review publisher must receive verified head SHA"
require_in_step "$publish_step" 'SEMANTIC_REVIEW_BASE_SHA' "semantic-review publisher must receive verified base SHA"
require_in_step "$publish_step" 'SEMANTIC_REVIEW_RUN_ID' "semantic-review publisher must receive verified run id"
require_in_step "$publish_step" 'require("./scripts/semantic-review-publish.js")' "semantic-review publisher must use the shared publisher script"
+36
View File
@@ -0,0 +1,36 @@
# Skill Format Check
This directory contains a script to validate the format of `SKILL.md` files located in the `../../skills` directory.
## Purpose
The `index.js` script ensures that all `SKILL.md` files conform to the standard template defined in `skill-template/skill-template.md`. Specifically, it checks that the YAML frontmatter includes the following fields:
- `name` (required)
- `description` (required)
- `metadata` (outputs a warning if missing, does not fail the build)
> **Note:** The `lark-shared` skill is explicitly excluded from these format checks.
## Usage
This script is executed automatically via GitHub Actions (`.github/workflows/skill-format-check.yml`) on pull requests and pushes that modify the `skills/` directory.
To run the check manually from the root of the repository, execute:
```bash
node scripts/skill-format-check/index.js
```
You can also specify a custom target directory as the first argument:
```bash
node scripts/skill-format-check/index.js ./path/to/my/skills
```
## Testing
This tool comes with a quick validation script to ensure it correctly identifies good and bad skill formats. To run the tests, execute:
```bash
./scripts/skill-format-check/test.sh
```
+99
View File
@@ -0,0 +1,99 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require('fs');
const path = require('path');
// Allow passing a target directory as the first argument.
// If provided, resolve against process.cwd() so it behaves as the user expects.
// If not provided, default to '../../skills' relative to this script's directory.
const targetDirArg = process.argv[2];
const SKILLS_DIR = targetDirArg
? path.resolve(process.cwd(), targetDirArg)
: path.resolve(__dirname, '../../skills');
function checkSkillFormat() {
console.log(`Checking skill format in ${SKILLS_DIR}...`);
if (!fs.existsSync(SKILLS_DIR)) {
console.error('Skills directory not found:', SKILLS_DIR);
process.exit(1);
}
let skills;
try {
skills = fs
.readdirSync(SKILLS_DIR, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name);
} catch (err) {
console.error(`Failed to enumerate skills directory: ${err.message}`);
process.exit(1);
}
let hasErrors = false;
skills.forEach(skill => {
// Skip lark-shared skill completely
if (skill === 'lark-shared') {
console.log(`⏭️ Skipping check for ${skill}`);
return;
}
const skillPath = path.join(SKILLS_DIR, skill);
const skillFile = path.join(skillPath, 'SKILL.md');
if (!fs.existsSync(skillFile)) {
console.error(`❌ [${skill}] Missing SKILL.md`);
hasErrors = true;
return;
}
let content;
try {
content = fs.readFileSync(skillFile, 'utf-8');
} catch (err) {
console.error(`❌ [${skill}] Failed to read SKILL.md: ${err.message}`);
hasErrors = true;
return;
}
// Normalize line endings to simplify parsing
const normalizedContent = content.replace(/\r\n/g, '\n');
// Check YAML Frontmatter
if (!normalizedContent.startsWith('---\n')) {
console.error(`❌ [${skill}] SKILL.md must start with YAML frontmatter (---)`);
hasErrors = true;
} else {
const frontmatterMatch = normalizedContent.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
if (!frontmatterMatch) {
console.error(`❌ [${skill}] SKILL.md has unclosed or invalid YAML frontmatter`);
hasErrors = true;
} else {
const frontmatter = frontmatterMatch[1];
if (!/^name:/m.test(frontmatter)) {
console.error(`❌ [${skill}] YAML frontmatter missing 'name'`);
hasErrors = true;
}
if (!/^description:/m.test(frontmatter)) {
console.error(`❌ [${skill}] YAML frontmatter missing 'description'`);
hasErrors = true;
}
if (!/^metadata:/m.test(frontmatter)) {
console.warn(`⚠️ [${skill}] YAML frontmatter missing 'metadata' (Warning only)`);
// hasErrors = true; // Downgrade to warning to not fail on existing skills
}
}
}
});
if (hasErrors) {
console.error('\n❌ Skill format check failed. Please fix the errors above.');
process.exit(1);
} else {
console.log('\n✅ Skill format check passed!');
}
}
checkSkillFormat();
+82
View File
@@ -0,0 +1,82 @@
#!/bin/bash
# Get the directory of this script
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
INDEX_JS="$DIR/index.js"
TEMP_DIR="$DIR/tests/temp_test_dir"
echo "=== Running tests for skill-format-check ==="
echo "Index script: $INDEX_JS"
prepare_fixture() {
local test_name=$1
rm -rf "$TEMP_DIR"
mkdir -p "$TEMP_DIR"
if [ ! -d "$DIR/tests/$test_name" ]; then
echo "❌ Missing fixture directory: $DIR/tests/$test_name"
exit 1
fi
cp -r "$DIR/tests/$test_name" "$TEMP_DIR/" || {
echo "❌ Failed to copy fixture: $test_name"
exit 1
}
}
# Function to run a positive test
run_positive_test() {
local test_name=$1
echo -e "\n--- [Positive] $test_name ---"
prepare_fixture "$test_name"
node "$INDEX_JS" "$TEMP_DIR"
if [ $? -eq 0 ]; then
echo "✅ Passed! (Correctly validated $test_name)"
rm -rf "$TEMP_DIR"
return 0
else
echo "❌ Failed! Expected $test_name to pass but it failed."
rm -rf "$TEMP_DIR"
exit 1
fi
}
# Function to run a negative test
run_negative_test() {
local test_name=$1
echo -e "\n--- [Negative] $test_name ---"
prepare_fixture "$test_name"
# Capture output for diagnostics while still treating non-zero as expected
local log_file="$TEMP_DIR/.validator.log"
node "$INDEX_JS" "$TEMP_DIR" > "$log_file" 2>&1
local exit_code=$?
if [ $exit_code -ne 0 ]; then
echo "✅ Passed! (Correctly rejected $test_name)"
rm -rf "$TEMP_DIR"
return 0
else
echo "❌ Failed! Expected $test_name to fail but it passed."
if [ -s "$log_file" ]; then
echo "--- Validator output ---"
cat "$log_file"
fi
rm -rf "$TEMP_DIR"
exit 1
fi
}
# Run positive tests
run_positive_test "good-skill"
run_positive_test "good-skill-minimal"
run_positive_test "good-skill-complex"
# Run negative tests
run_negative_test "bad-skill"
run_negative_test "bad-skill-no-frontmatter"
run_negative_test "bad-skill-unclosed-frontmatter"
echo -e "\n🎉 All tests passed successfully!"
@@ -0,0 +1,3 @@
# No Frontmatter Skill
This skill completely lacks a YAML frontmatter.
@@ -0,0 +1,9 @@
---
name: bad-skill-unclosed
version: 1.0.0
description: "This skill has an unclosed frontmatter block."
metadata: {}
# Unclosed Frontmatter Skill
This frontmatter does not have a closing `---` block.
@@ -0,0 +1,8 @@
---
version: 1.0.0
metadata: {}
---
# Bad Skill
This skill is missing required fields like name and description.
@@ -0,0 +1,17 @@
---
name: good-skill-complex
version: 2.5.1-beta
description: >
A very complex description
that spans multiple lines
and contains weird chars: !@#$%^&*()
metadata:
requires:
bins: ["lark-cli", "node"]
cliHelp: "lark-cli something --help"
customField: "customValue"
---
# Complex Skill
This skill has a complex frontmatter block.
@@ -0,0 +1,10 @@
---
name: good-skill-minimal
version: 0.1.0
description: Minimal valid description
metadata: {}
---
# Minimal Skill
This has the bare minimum required fields.
@@ -0,0 +1,12 @@
---
name: good-skill
version: 1.0.0
description: "This is a properly formatted skill."
metadata:
requires:
bins: ["lark-cli"]
---
# Good Skill
This skill follows all the formatting rules.
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Read version from package.json
VERSION=$(node -p "require('${REPO_ROOT}/package.json').version")
if [ -z "$VERSION" ]; then
echo "Error: could not read version from package.json" >&2
exit 1
fi
TAG="v${VERSION}"
echo "Version: ${VERSION}"
echo "Tag: ${TAG}"
# Check if tag already exists locally
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag ${TAG} already exists locally, skipping."
exit 0
fi
# Check if tag already exists on remote
if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then
echo "Tag ${TAG} already exists on remote, skipping."
exit 0
fi
# Ensure package.json changes are committed before tagging
if git diff --name-only | grep -q 'package.json' || git diff --cached --name-only | grep -q 'package.json'; then
echo "Error: package.json has uncommitted changes. Please commit before tagging." >&2
exit 1
fi
# Ensure current branch is pushed to remote before tagging
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
LOCAL_SHA=$(git rev-parse HEAD)
REMOTE_SHA=$(git rev-parse "origin/${CURRENT_BRANCH}" 2>/dev/null || echo "")
if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
echo "Error: local branch '${CURRENT_BRANCH}' is not in sync with remote. Please push your commits first." >&2
exit 1
fi
# Create and push tag
git tag "$TAG"
git push origin "$TAG"
echo "Successfully created and pushed tag ${TAG}"