@lark-project/cli
Version:
飞书项目插件开发工具
349 lines (348 loc) • 16.4 kB
JavaScript
;
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;