UNPKG

@lark-project/cli

Version:

飞书项目插件开发工具

349 lines (348 loc) 16.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.formatValidationErrors = exports.getPointSchema = exports.validatePointConfig = exports.fetchAndCacheSchema = void 0; const ajv_1 = __importDefault(require("ajv")); const ajv_formats_1 = __importDefault(require("ajv-formats")); const get_plugin_schema_1 = require("../api/get-plugin-schema"); const local_plugin_config_1 = require("../local-plugin-config"); const ajv = new ajv_1.default({ allErrors: true, strict: false }); (0, ajv_formats_1.default)(ajv); // ── Custom Keywords, e.g. builder -> extension ────────────────────────────────────── // x-unique-fields: check that specified fields are unique across array items // Schema usage: { type: array, x-unique-fields: ["name", "prop_key"] } ajv.addKeyword({ keyword: 'x-unique-fields', type: 'array', validate: function xUniqueFields(fields, data, _parentSchema, _dataCxt) { var _a; if (!Array.isArray(data) || !Array.isArray(fields)) return true; for (const field of fields) { const seen = new Map(); for (let i = 0; i < data.length; i++) { const val = JSON.stringify((_a = data[i]) === null || _a === void 0 ? void 0 : _a[field]); if (val === undefined || val === 'undefined') continue; const prev = seen.get(val); if (prev !== undefined) { xUniqueFields.errors = [{ keyword: 'x-unique-fields', message: `items[${prev}] and items[${i}] have duplicate "${field}": ${val}`, params: { field, duplicateIndex: [prev, i] }, }]; return false; } seen.set(val, i); } } return true; }, errors: true, }); // x-cross-unique-fields: check that given fields are unique across multiple arrays in the same parent object // Schema usage: { x-cross-unique-fields: [[["properties", "outputs"], "prop_key", "name"]] } // - first element: list of array-valued property names on the parent object to merge // - remaining elements: one or more field names inside each array item; each listed field must be // globally unique across all merged items (checked independently) ajv.addKeyword({ keyword: 'x-cross-unique-fields', validate: function xCrossUniqueFields(rules, data) { var _a; if (typeof data !== 'object' || data === null || !Array.isArray(rules)) return true; for (const rule of rules) { if (!Array.isArray(rule) || rule.length < 2) continue; const [arrays, ...fields] = rule; if (!Array.isArray(arrays)) continue; for (const field of fields) { if (typeof field !== 'string') continue; const seen = new Map(); for (const arrName of arrays) { const arr = data[arrName]; if (!Array.isArray(arr)) continue; for (let i = 0; i < arr.length; i++) { const val = JSON.stringify((_a = arr[i]) === null || _a === void 0 ? void 0 : _a[field]); if (val === undefined || val === 'undefined') continue; const prev = seen.get(val); if (prev !== undefined) { xCrossUniqueFields.errors = [{ keyword: 'x-cross-unique-fields', message: `${prev.source}[${prev.index}] and ${arrName}[${i}] have duplicate "${field}": ${val}`, params: { field, arrays, duplicate: [{ source: prev.source, index: prev.index }, { source: arrName, index: i }] }, }]; return false; } seen.set(val, { source: arrName, index: i }); } } } } return true; }, errors: true, }); // x-field-lte: check that field A <= field B // Schema usage: { x-field-lte: [["minW", "maxW", "minW must be <= maxW"], ...] } ajv.addKeyword({ keyword: 'x-field-lte', validate: function xFieldLte(rules, data) { if (typeof data !== 'object' || data === null || !Array.isArray(rules)) return true; for (const [fieldA, fieldB, message] of rules) { const a = data[fieldA]; const b = data[fieldB]; if (typeof a === 'number' && typeof b === 'number' && a > b) { xFieldLte.errors = [{ keyword: 'x-field-lte', message: message || `${fieldA} must be <= ${fieldB}`, params: { fieldA, fieldB, valueA: a, valueB: b }, }]; return false; } } return true; }, errors: true, }); // x-field-range: check that field V is between field Min and field Max // Schema usage: { x-field-range: [["presetW", "minW", "maxW", "presetW must be between minW and maxW"], ...] } ajv.addKeyword({ keyword: 'x-field-range', validate: function xFieldRange(rules, data) { if (typeof data !== 'object' || data === null || !Array.isArray(rules)) return true; for (const [field, minField, maxField, message] of rules) { const v = data[field]; const lo = data[minField]; const hi = data[maxField]; if (typeof v === 'number' && typeof lo === 'number' && typeof hi === 'number') { if (v < lo || v > hi) { xFieldRange.errors = [{ keyword: 'x-field-range', message: message || `${field} must be between ${minField} and ${maxField}`, params: { field, minField, maxField, value: v, min: lo, max: hi }, }]; return false; } } } return true; }, errors: true, }); // dslSchema: validate DSL content — works for both string (table_cell) and object (table_layout) // Schema usage: { dslSchema: { type: object, properties: { definitions: ..., template: ... } } } // When data is a string, it is first JSON.parsed; when data is an object, it is validated directly. // Additionally checks that the template tree depth does not exceed 5. ajv.addKeyword({ keyword: 'dslSchema', modifying: false, validate: function xDslSchema(schema, data, _parentSchema, _dataCxt) { if (data === undefined || data === null) return true; if (!schema || typeof schema !== 'object') return true; // 1. Parse string → object if needed let obj; if (typeof data === 'string') { try { obj = JSON.parse(data); } catch (_a) { xDslSchema.errors = [{ keyword: 'dslSchema', message: 'must be valid JSON string', params: {}, }]; return false; } } else if (typeof data === 'object') { obj = data; } else { return true; // not our concern } // 1.5 兼容裸 template:若顶层有 `type` 字段且无 `template`,包装成 { template } 再校验 // 仅当 dslSchema 自身在顶层属性里声明了 `template`(即 table_cell / table_layout 这类 DSL)才走包装; // 像 PageIconSchema / ViewIconSchema 这种 dslSchema 顶层就是 {color, type} 的不能误判。 const schemaAcceptsTemplate = (schema === null || schema === void 0 ? void 0 : schema.properties) && 'template' in schema.properties; if (schemaAcceptsTemplate && obj && typeof obj === 'object' && typeof obj.type === 'string' && !('template' in obj)) { obj = { template: obj }; } // 2. Validate against dslSchema structure using a fresh AJV compile // The template schema is recursive (children.items.$ref → template), so we // strip the recursive $ref before compiling and rely on depth-check instead. const dslSchemaResolved = JSON.parse(JSON.stringify(schema)); const stripRecursiveRefs = (node, visited) => { if (!node || typeof node !== 'object' || visited.has(node)) return; visited.add(node); // Remove $ref in children.items to break the cycle if (node.items && node.items.$ref && typeof node.items.$ref === 'string') { delete node.items.$ref; // Keep remaining constraints (type, etc.) if any if (Object.keys(node.items).length === 0) { node.items = { type: 'object' }; } } for (const val of Object.values(node)) { if (typeof val === 'object' && val !== null) { stripRecursiveRefs(val, visited); } } }; stripRecursiveRefs(dslSchemaResolved, new Set()); const innerAjv = new ajv_1.default({ allErrors: true, strict: false }); let validate; try { validate = innerAjv.compile(dslSchemaResolved); } catch (_b) { // If schema compilation fails, skip validation return true; } if (!validate(obj)) { const errors = validate.errors || []; xDslSchema.errors = errors.map((e) => ({ keyword: 'dslSchema', message: `DSL${e.instancePath} ${e.message}`, params: e.params, })); return false; } // 3. Check template nesting depth ≤ 5 const getDepth = (node, current) => { if (!node || typeof node !== 'object') return current; if (!Array.isArray(node.children) || node.children.length === 0) return current; let maxChild = current; for (const child of node.children) { const d = getDepth(child, current + 1); if (d > maxChild) maxChild = d; } return maxChild; }; const depth = getDepth(obj.template || obj, 1); if (depth > 5) { xDslSchema.errors = [{ keyword: 'dslSchema', message: `template nesting depth ${depth} exceeds maximum of 5`, params: { depth, max: 5 }, }]; return false; } return true; }, errors: true, }); // ── Schema Cache & Validation ──────────────────────────── let cachedSchema = null; let cachedValidate = null; // 顶层合法 point-type key(aiField / aiNode / liteAppComponent / …), // 用于在 additionalProperties 校验失败时给出定位提示。 let cachedTopLevelKeys = null; async function fetchAndCacheSchema() { if (cachedSchema) { return cachedSchema; } const localConfig = (0, local_plugin_config_1.getLocalPluginConfig)(); if (!localConfig) { throw new Error('Local plugin config not found. Please init project first.'); } cachedSchema = await (0, get_plugin_schema_1.getPluginPointSchema)(localConfig.siteDomain, localConfig.pluginId); return cachedSchema; } exports.fetchAndCacheSchema = fetchAndCacheSchema; async function validatePointConfig(config) { var _a, _b, _c; if (!cachedValidate) { const schema = await fetchAndCacheSchema(); // schema is { integrate_point_schema: { type, properties, definitions, ... } } // $ref paths use "#/integrate_point_schema/definitions/..." which requires // the root object to have integrate_point_schema as a direct key. // // The raw schema object has no root-level JSON Schema keywords (type, properties, etc.), // so AJV would treat it as an empty schema that validates everything. // Fix: create a wrapper schema that uses $ref to delegate validation to the actual schema, // while keeping the full structure so nested $ref paths resolve correctly. const wrapperSchema = { type: 'object', required: ['integrate_point_schema'], properties: { integrate_point_schema: { $ref: '#/integrate_point_schema' }, }, // Keep the actual schema at this key so all $ref paths like // "#/integrate_point_schema/definitions/..." resolve correctly integrate_point_schema: schema.integrate_point_schema, }; cachedValidate = ajv.compile(wrapperSchema); cachedTopLevelKeys = Object.keys((_b = (_a = schema.integrate_point_schema) === null || _a === void 0 ? void 0 : _a.properties) !== null && _b !== void 0 ? _b : {}); } // Wrap data so it matches the schema structure const valid = cachedValidate({ integrate_point_schema: config }); if (!valid) { return { valid: false, errors: ((_c = cachedValidate.errors) === null || _c === void 0 ? void 0 : _c.map(err => { var _a; // Strip the "/integrate_point_schema" prefix from error paths for cleaner output const path = (err.instancePath || '').replace(/^\/integrate_point_schema/, ''); // 顶层 additionalProperties 失败时(path 为空),光报 "must NOT have additional // properties" 无法定位——补上 offending key 与合法顶层 key 列表,省去试错 // wrapper(ai_field 蛇形 / 裸对象 / aiField)。见 ISSUE-set-not-pushed-to-remote P2。 if (err.keyword === 'additionalProperties' && path === '' && cachedTopLevelKeys && cachedTopLevelKeys.length > 0) { const bad = (_a = err.params) === null || _a === void 0 ? void 0 : _a.additionalProperty; return `${path} ${err.message}: unknown top-level key${bad ? ` "${bad}"` : ''}. Valid top-level point-type keys are: ${cachedTopLevelKeys.join(', ')}`; } return `${path} ${err.message}`; })) || [], }; } return { valid: true }; } exports.validatePointConfig = validatePointConfig; async function getPointSchema() { return await fetchAndCacheSchema(); } exports.getPointSchema = getPointSchema; // AJV path is space-free (e.g. /liteAppComponent/0/properties); split on the first space // gives AI a structured `{ path, message }` for auto-fix without coupling callers to the // "<path> <message>" join shape. function splitErrorLine(line) { const idx = line.indexOf(' '); if (idx < 0) return { path: '', message: line }; return { path: line.slice(0, idx), message: line.slice(idx + 1) }; } function formatValidationErrors(result, format) { // `lines` exposes per-error strings explicitly so callers iterate the array directly // instead of `stdout.split('\n')` — `''.split('\n') === ['']` would emit a blank // line for valid results that happen to flow through the text path. // Invariant: `lines.length === 0` ⟺ `result.errors` is undefined or empty (typically // when `result.valid === true`). const lines = result.errors || []; const structured = lines.map(splitErrorLine); if (format === 'json') { return { stdout: JSON.stringify({ valid: result.valid, errors: structured }), structured, lines }; } return { stdout: lines.join('\n'), structured, lines }; } exports.formatValidationErrors = formatValidationErrors;