UNPKG

@lark-project/cli

Version:

飞书项目插件开发工具

228 lines (227 loc) 9.59 kB
"use strict"; 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, '\\$&'); }