import { existsSync } from 'node:fs' import { parse as parseVueSfc } from 'vue/compiler-sfc' function normalizedFilename(context) { return (context.filename ?? context.getFilename?.() ?? '').replace(/\\/g, '/') } function importSource(node) { return typeof node.source?.value === 'string' ? node.source.value : null } function createProgramFilenameRule({ description, check }) { return { meta: { docs: { description } }, create(context) { const file = normalizedFilename(context) const message = check(file) if (!message) return {} return { Program(node) { context.report({ node, message }) } } } } } function createImportSourceRule({ description, applies = () => true, includeExports = false, includeDynamic = false, check }) { return { meta: { docs: { description } }, create(context) { const file = normalizedFilename(context) if (!applies(file)) return {} function reportIfRestricted(node) { const source = importSource(node) if (!source) return const message = check(source, file) if (!message) return context.report({ node, message }) } const visitors = { ImportDeclaration: reportIfRestricted } if (includeExports) { visitors.ExportAllDeclaration = reportIfRestricted visitors.ExportNamedDeclaration = reportIfRestricted } if (includeDynamic) visitors.ImportExpression = reportIfRestricted return visitors } } } function isUnknownTypeAnnotation(typeAnnotation) { return typeAnnotation?.type === 'TSUnknownKeyword' } const noInlineNamedTypes = { meta: { docs: { description: 'Disallow inline type literals that duplicate a named type' }, schema: [ { type: 'object', additionalProperties: { type: 'string' } } ] }, create(context) { const typesOption = context.options[0] if (!typesOption || typeof typesOption !== 'object') return {} const shapeToName = new Map() for (const [name, shape] of Object.entries(typesOption)) { shapeToName.set(shape, name) } return { TSTypeLiteral(node) { const props = node.members?.filter( (m) => m.type === 'TSPropertySignature' && m.key?.type === 'Identifier' ) if (!props || props.length < 2) return const shape = props .map((m) => { const typeNode = m.typeAnnotation?.typeAnnotation let typeName = 'unknown' if (typeNode) { switch (typeNode.type) { case 'TSNumberKeyword': typeName = 'number' break case 'TSStringKeyword': typeName = 'string' break case 'TSBooleanKeyword': typeName = 'boolean' break } } return `${m.key.name}:${typeName}` }) .sort() .join(',') const namedType = shapeToName.get(shape) if (namedType) { context.report({ node, message: `Use '${namedType}' instead of inline type literal. Import from '@open-pencil/core'.` }) } } } } } const noStructuredCloneSceneArrays = { meta: { docs: { description: 'Disallow structuredClone on fills/strokes/effects — use typed copy helpers from copy.ts' }, schema: [ { type: 'array', items: { type: 'string' }, description: 'Property names that should use typed copy helpers' } ] }, create(context) { const props = new Set( context.options[0] ?? [ 'fills', 'strokes', 'effects', 'styleRuns', 'fillGeometry', 'strokeGeometry' ] ) return { CallExpression(node) { if (node.callee?.type !== 'Identifier' || node.callee.name !== 'structuredClone') return if (node.arguments?.length !== 1) return const arg = node.arguments[0] if (arg.type === 'MemberExpression' && arg.property?.type === 'Identifier') { if (props.has(arg.property.name)) { context.report({ node, message: `Use the typed copy helper instead of structuredClone for '${arg.property.name}'. Import from '@open-pencil/core'.` }) } } } } } } function vueSfcDescriptor(source, filename) { return parseVueSfc(source, { filename }).descriptor } function vueTemplateAst(source, filename) { return vueSfcDescriptor(source, filename).template?.ast ?? null } function isVueSourceFile(file) { return ( file.endsWith('.vue') && (file.startsWith('src/') || file.includes('/src/') || file.startsWith('packages/vue/src/') || file.includes('/packages/vue/src/')) ) } function sourceLineCount(source) { const normalized = source.endsWith('\n') ? source.slice(0, -1) : source return normalized.split('\n').length } const VUE_ELEMENT_NODE = 1 const VUE_SIMPLE_EXPRESSION_NODE = 4 const VUE_INTERPOLATION_NODE = 5 const VUE_ATTRIBUTE_NODE = 6 const VUE_DIRECTIVE_NODE = 7 function walkVueTemplateAst(node, visitor) { visitor(node) for (const prop of node.props ?? []) walkVueTemplateAst(prop, visitor) for (const child of node.children ?? []) walkVueTemplateAst(child, visitor) if (node.type === VUE_INTERPOLATION_NODE && node.content) { walkVueTemplateAst(node.content, visitor) } if (node.type === VUE_DIRECTIVE_NODE) { if (node.arg) walkVueTemplateAst(node.arg, visitor) if (node.exp) walkVueTemplateAst(node.exp, visitor) } } function walkExpressionAst(node, visitor) { if (!node || typeof node !== 'object') return visitor(node) for (const value of Object.values(node)) { if (!value || value === node.loc) continue if (Array.isArray(value)) { for (const item of value) walkExpressionAst(item, visitor) continue } walkExpressionAst(value, visitor) } } function isUiHelperName(name) { const prefix = 'use' const suffix = 'UI' if (!name.startsWith(prefix) || !name.endsWith(suffix)) return false const firstDomainChar = name.at(prefix.length) return firstDomainChar !== undefined && firstDomainChar === firstDomainChar.toUpperCase() } function hasExpressionCall(expression, predicate) { if (expression?.type !== VUE_SIMPLE_EXPRESSION_NODE || !expression.ast) return false let found = false walkExpressionAst(expression.ast, (node) => { if (found || node.type !== 'CallExpression') return if (node.callee?.type === 'Identifier' && predicate(node.callee.name)) found = true }) return found } function hasUiHelperCall(expression) { return hasExpressionCall(expression, isUiHelperName) } function isStaticVueAttribute(node, name) { return node.type === VUE_ATTRIBUTE_NODE && node.name === name } function isVueBindDirective(node, name) { return node.type === VUE_DIRECTIVE_NODE && node.name === 'bind' && node.arg?.content === name } function isBoundStringLiteral(node, name) { if (!isVueBindDirective(node, name)) return false const expression = node.exp if (expression?.type !== VUE_SIMPLE_EXPRESSION_NODE) return false const ast = expression.ast if (!ast) return false if (ast.type === 'StringLiteral' || (ast.type === 'Literal' && typeof ast.value === 'string')) { return true } return ast.type === 'TemplateLiteral' && ast.expressions?.length === 0 } const noVueStyleBlocks = { meta: { docs: { description: 'Disallow Vue component