@lark-project/cli
Version:
飞书项目插件开发工具
228 lines (227 loc) • 9.59 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.auditJssdk = void 0;
const path_1 = __importDefault(require("path"));
const fs_extra_1 = require("fs-extra");
const child_process_1 = require("child_process");
const DEFAULT_TYPES = 'node_modules/@lark-project/js-sdk/dist/types/index.d.ts';
const DEFAULT_SEARCH_ROOT = 'src/features';
const JSSDK_CALL_RE = /window\.JSSDK\.([a-zA-Z_][\w]*)\.([a-zA-Z_][\w]*)\s*\(/g;
/**
* Audit `window.JSSDK.<ns>.<method>(` calls against a types declaration file.
*
* Why native: bash + grep + awk + <<< heredoc is fast to read but not
* portable — Windows devs and agents that can't drop into bash can't run it.
* This command gives every agent the same audit in one command.
*/
function auditJssdk(opts) {
const format = opts.format === 'json' ? 'json' : 'text';
const typesPath = opts.types || DEFAULT_TYPES;
if (!(0, fs_extra_1.existsSync)(typesPath)) {
// 非硬错:告知 + exit 0,让上层 skill 知道审计被跳过而非失败
process.stderr.write(`SKIP: js-sdk types not found at "${typesPath}" — audit skipped. Install the package or pass --types <path>.\n`);
process.exit(0);
}
const files = resolveFiles(opts.files);
if (files.length === 0) {
if (format === 'json') {
process.stdout.write(JSON.stringify({ count: 0, violations: [], scanned: 0 }) + '\n');
}
else {
process.stdout.write('ok (no files to scan)\n');
}
return;
}
const typesContent = (0, fs_extra_1.readFileSync)(typesPath, 'utf8');
const nsToClass = extractNsToClass(typesContent);
const classMembersCache = new Map();
const getMembers = (className) => {
if (!classMembersCache.has(className)) {
classMembersCache.set(className, extractClassMembers(className, typesContent));
}
return classMembersCache.get(className);
};
const violations = [];
for (const file of files) {
if (!(0, fs_extra_1.existsSync)(file))
continue;
const lines = (0, fs_extra_1.readFileSync)(file, 'utf8').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
JSSDK_CALL_RE.lastIndex = 0;
let m;
while ((m = JSSDK_CALL_RE.exec(line)) !== null) {
const ns = m[1];
const method = m[2];
// Fallback: types 文件无 SDKClient class block → 退化为 method-only 校验(向后兼容)
if (nsToClass.size === 0) {
if (!methodExistsInTypes(method, typesContent)) {
violations.push({
file, line: i + 1, method, namespace: ns, content: line.trim(),
reason: `method "${method}" not in types`,
});
}
continue;
}
const className = nsToClass.get(ns);
if (!className) {
violations.push({
file, line: i + 1, method, namespace: ns, content: line.trim(),
reason: `unknown namespace "JSSDK.${ns}" — not a SDKClient getter (valid: ${[...nsToClass.keys()].sort().join(', ')})`,
});
continue;
}
const members = getMembers(className);
if (!members.has(method)) {
// 在其他 ns 下找该 method,给 "did you mean" 提示
let suggestion = '';
for (const [otherNs, otherClass] of nsToClass) {
if (otherNs === ns)
continue;
if (getMembers(otherClass).has(method)) {
suggestion = ` — did you mean "window.JSSDK.${otherNs}.${method}(...)"?`;
break;
}
}
violations.push({
file, line: i + 1, method, namespace: ns, content: line.trim(),
reason: `method "${method}" not on class ${className} (ns "${ns}")${suggestion}`,
});
}
}
}
}
if (format === 'json') {
process.stdout.write(JSON.stringify({ count: violations.length, violations, scanned: files.length }) + '\n');
}
else if (violations.length === 0) {
process.stdout.write(`ok (scanned ${files.length} file${files.length === 1 ? '' : 's'}, no violations)\n`);
}
else {
for (const v of violations) {
process.stdout.write(`${v.file}:${v.line} | ${v.reason} | ${v.content}\n`);
}
process.stdout.write(`\n${violations.length} violation(s) across ${files.length} file(s).\n`);
}
process.exit(violations.length > 0 ? 1 : 0);
}
exports.auditJssdk = auditJssdk;
function resolveFiles(filesOpt) {
// 1) Explicit --files (comma-separated)
if (filesOpt && filesOpt.trim()) {
return filesOpt
.split(',')
.map(s => s.trim())
.filter(Boolean);
}
// 2) git diff changed files (if in a git repo)
const gitChanged = tryGitDiff();
if (gitChanged.length > 0)
return gitChanged;
// 3) fallback: recursively scan DEFAULT_SEARCH_ROOT for .ts / .tsx
if (!(0, fs_extra_1.existsSync)(DEFAULT_SEARCH_ROOT))
return [];
return walk(DEFAULT_SEARCH_ROOT).filter(f => /\.(tsx?|jsx?)$/.test(f));
}
function tryGitDiff() {
try {
const out = (0, child_process_1.execSync)('git diff --name-only', {
stdio: ['ignore', 'pipe', 'ignore'],
}).toString();
return out
.split('\n')
.map(s => s.trim())
.filter(f => f && /\.(tsx?|jsx?)$/.test(f) && f.startsWith(`${DEFAULT_SEARCH_ROOT}/`));
}
catch (_a) {
return [];
}
}
function walk(dir) {
const out = [];
for (const name of (0, fs_extra_1.readdirSync)(dir)) {
const full = path_1.default.join(dir, name);
let st;
try {
st = (0, fs_extra_1.statSync)(full);
}
catch (_a) {
continue;
}
if (st.isDirectory()) {
if (name === 'node_modules' || name.startsWith('.'))
continue;
out.push(...walk(full));
}
else if (st.isFile()) {
out.push(full);
}
}
return out;
}
/**
* Check whether `method` appears as a class/type member declaration in the
* .d.ts content. We use the same heuristic as the legacy bash script —
* "method is followed by `?:<(\[` character" covers optional props, typed
* props, generic methods, function signatures, and index/tuple types.
*
* Used as fallback when the types file has no `class SDKClient` block.
*/
function methodExistsInTypes(method, typesContent) {
// (^|non-word char) + method + whitespace* + (one of ?:<(\[)
const re = new RegExp(`(^|[^A-Za-z0-9_])${escapeRegex(method)}\\s*[?:<(\\[]`, 'm');
return re.test(typesContent);
}
/**
* Extract the namespace → class mapping from the SDKClient class body.
* SDKClient exposes each namespace via a getter like `get liteAppComponent(): BuilderComponent;`
* — we map "liteAppComponent" → "BuilderComponent" so we can validate
* `window.JSSDK.liteAppComponent.<method>` calls against the BuilderComponent class members.
*/
function extractNsToClass(typesContent) {
const map = new Map();
// Match `(declare )? class SDKClient ... { ... }` — find the class body
const classRe = /(?:declare\s+)?class\s+SDKClient\b[^{]*\{([\s\S]*?)\n\}/m;
const classMatch = typesContent.match(classRe);
if (!classMatch)
return map;
const body = classMatch[1];
const getterRe = /\bget\s+(\w+)\s*\(\s*\)\s*:\s*(\w+)\s*;/g;
let m;
while ((m = getterRe.exec(body)) !== null) {
map.set(m[1], m[2]);
}
return map;
}
/**
* Extract member names declared inside a given class body (handles abstract / regular,
* optional / typed / generic / function-signature members — same heuristic as methodExistsInTypes).
*/
function extractClassMembers(className, typesContent) {
const members = new Set();
// Match `(declare )? (abstract )? class <name> ... { ... }`
const classRe = new RegExp(`(?:declare\\s+)?(?:abstract\\s+)?class\\s+${escapeRegex(className)}\\b[^{]*\\{([\\s\\S]*?)\\n\\}`, 'm');
const classMatch = typesContent.match(classRe);
if (!classMatch)
return members;
const body = classMatch[1];
// Strip block comments to avoid pulling identifiers out of /** ... */ docs
const stripped = body.replace(/\/\*[\s\S]*?\*\//g, '');
// Member name is followed by `?:<(\[` (same as methodExistsInTypes heuristic)
const memberRe = /(?:^|\n)\s*(?:abstract\s+|protected\s+|private\s+|static\s+|readonly\s+)*(\w+)\s*[?:<(\[]/g;
let m;
while ((m = memberRe.exec(stripped)) !== null) {
const name = m[1];
// Skip TS modifier keywords that the regex may capture
if (name === 'abstract' || name === 'protected' || name === 'private' || name === 'static' || name === 'readonly')
continue;
members.add(name);
}
return members;
}
function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}