test(arch): guard panel UI conventions

This commit is contained in:
Danila Poyarkov 2026-06-30 21:50:05 +03:00
parent 3f08b80b5e
commit cde9cd4610
2 changed files with 323 additions and 67 deletions

View file

@ -1,4 +1,5 @@
import { existsSync } from 'node:fs'
import { parse as parseVueSfc } from 'vue/compiler-sfc'
function normalizedFilename(context) {
return (context.filename ?? context.getFilename?.() ?? '').replace(/\\/g, '/')
@ -172,6 +173,97 @@ const noStructuredCloneSceneArrays = {
}
}
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.includes('/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: {
@ -185,14 +277,13 @@ const noVueStyleBlocks = {
return {
Program(node) {
const source = context.sourceCode.getText()
if (/<style\b/i.test(source)) {
context.report({
node,
message:
'Vue components must not use <style> blocks. Use Tailwind utilities or global app.css tokens.'
})
}
const descriptor = vueSfcDescriptor(context.sourceCode.getText(), file)
if (descriptor.styles.length === 0) return
context.report({
node,
message:
'Vue components must not use <style> blocks. Use Tailwind utilities or global app.css tokens.'
})
}
}
}
@ -211,13 +302,20 @@ const noNativeTitleAttributesInVue = {
return {
Program(node) {
const source = context.sourceCode.getText()
if (/\s:?title=/.test(source)) {
context.report({
node,
message: 'Use the shared tooltip UI instead of native title attributes.'
})
}
const template = vueTemplateAst(context.sourceCode.getText(), file)
if (!template) return
let hasTitleAttribute = false
walkVueTemplateAst(template, (templateNode) => {
if (hasTitleAttribute) return
if (isStaticVueAttribute(templateNode, 'title') || isVueBindDirective(templateNode, 'title')) {
hasTitleAttribute = true
}
})
if (!hasTitleAttribute) return
context.report({
node,
message: 'Use the shared tooltip UI instead of native title attributes.'
})
}
}
}
@ -236,13 +334,110 @@ const noHardcodedTipLabelsInVue = {
return {
Program(node) {
const source = context.sourceCode.getText()
if (/<Tip\b[^>]*\slabel=["']/.test(source)) {
context.report({
node,
message: 'Use a localized binding for Tip labels, not a hardcoded string.'
})
}
const template = vueTemplateAst(context.sourceCode.getText(), file)
if (!template) return
let hasHardcodedTipLabel = false
walkVueTemplateAst(template, (templateNode) => {
if (hasHardcodedTipLabel) return
if (templateNode.type !== VUE_ELEMENT_NODE || templateNode.tag !== 'Tip') return
hasHardcodedTipLabel = templateNode.props?.some(
(prop) => isStaticVueAttribute(prop, 'label') || isBoundStringLiteral(prop, 'label')
)
})
if (!hasHardcodedTipLabel) return
context.report({
node,
message: 'Use a localized binding for Tip labels, not a hardcoded string.'
})
}
}
}
}
const noRawSvgInAppVueTemplates = {
meta: {
docs: {
description: 'Disallow raw SVG in app Vue templates — use Iconify/unplugin icons'
}
},
create(context) {
const file = normalizedFilename(context)
if (!isVueSourceFile(file)) return {}
return {
Program(node) {
const template = vueTemplateAst(context.sourceCode.getText(), file)
if (!template) return
let hasRawSvg = false
walkVueTemplateAst(template, (templateNode) => {
if (templateNode.type === VUE_ELEMENT_NODE && templateNode.tag === 'svg') {
hasRawSvg = true
}
})
if (!hasRawSvg) return
context.report({
node,
message: 'Use Iconify/unplugin icon components instead of raw <svg> in app templates.'
})
}
}
}
}
const noUiHelperCallsInVueTemplates = {
meta: {
docs: {
description: 'Disallow use*UI() helper calls inside Vue templates'
}
},
create(context) {
const file = normalizedFilename(context)
if (!file.endsWith('.vue') || !file.includes('/src/components/')) return {}
return {
Program(node) {
const template = vueTemplateAst(context.sourceCode.getText(), file)
if (!template) return
let hasTemplateUiHelperCall = false
walkVueTemplateAst(template, (templateNode) => {
if (!hasTemplateUiHelperCall && hasUiHelperCall(templateNode)) {
hasTemplateUiHelperCall = true
}
})
if (!hasTemplateUiHelperCall) return
context.report({
node,
message: 'Hoist use*UI() calls out of templates or hide them inside shared UI components.'
})
}
}
}
}
const PROPERTY_SECTION_LINE_ALLOWLIST = new Set([
'/src/components/properties/LayoutSection/SizeControls.vue'
])
const noLargePropertySectionComponents = {
meta: {
docs: {
description: 'Disallow oversized property-section Vue components'
}
},
create(context) {
const file = normalizedFilename(context)
if (!file.endsWith('.vue') || !file.includes('/src/components/properties/')) return {}
if ([...PROPERTY_SECTION_LINE_ALLOWLIST].some((suffix) => file.endsWith(suffix))) return {}
return {
Program(node) {
const lineCount = sourceLineCount(context.sourceCode.getText())
if (lineCount <= 250) return
context.report({
node,
message:
'Split property-section components over 250 lines into focused controls or document an explicit allowlist.'
})
}
}
}
@ -298,13 +493,18 @@ const noDynamicDataTestIdInVue = {
return {
Program(node) {
const source = context.sourceCode.getText()
if (/\B:data-test-id\s*=|\bv-bind:data-test-id\s*=/.test(source)) {
context.report({
node,
message: 'Use v-test-id for dynamic/configurable test ids instead of :data-test-id.'
})
}
const template = vueTemplateAst(context.sourceCode.getText(), file)
if (!template) return
let hasDynamicDataTestId = false
walkVueTemplateAst(template, (templateNode) => {
if (hasDynamicDataTestId) return
if (isVueBindDirective(templateNode, 'data-test-id')) hasDynamicDataTestId = true
})
if (!hasDynamicDataTestId) return
context.report({
node,
message: 'Use v-test-id for dynamic/configurable test ids instead of :data-test-id.'
})
}
}
}
@ -322,13 +522,23 @@ const noTestIdHelperBindInVue = {
return {
Program(node) {
const source = context.sourceCode.getText()
if (/\bv-bind\s*=\s*"testId(?:Attr)?\(/.test(source)) {
context.report({
node,
message: 'Use v-test-id instead of v-bind="testId(...)" in Vue templates.'
})
}
const template = vueTemplateAst(context.sourceCode.getText(), file)
if (!template) return
let hasTestIdHelperBind = false
walkVueTemplateAst(template, (templateNode) => {
if (hasTestIdHelperBind) return
if (templateNode.type !== VUE_DIRECTIVE_NODE || templateNode.name !== 'bind') return
if (templateNode.arg) return
hasTestIdHelperBind = hasExpressionCall(
templateNode.exp,
(name) => name === 'testId' || name === 'testIdAttr'
)
})
if (!hasTestIdHelperBind) return
context.report({
node,
message: 'Use v-test-id instead of v-bind="testId(...)" in Vue templates.'
})
}
}
}
@ -346,26 +556,35 @@ const noInvalidTestIdAttributes = {
return {
Program(node) {
const source = context.sourceCode.getText()
const invalidAttr = source.match(/\bdata-testid\s*=/)
if (invalidAttr) {
const template = vueTemplateAst(context.sourceCode.getText(), file)
if (!template) return
let invalidId = null
let hasInvalidSpelling = false
walkVueTemplateAst(template, (templateNode) => {
if (hasInvalidSpelling || invalidId !== null) return
if (
isStaticVueAttribute(templateNode, 'data-testid') ||
isVueBindDirective(templateNode, 'data-testid')
) {
hasInvalidSpelling = true
return
}
if (!isStaticVueAttribute(templateNode, 'data-test-id')) return
const id = templateNode.value?.content ?? ''
if (!TEST_ID_FORMAT.test(id)) invalidId = id
})
if (hasInvalidSpelling) {
context.report({
node,
message: 'Use data-test-id instead of data-testid.'
})
return
}
const attrPattern = /\bdata-test-id\s*=\s*"([^"]+)"/g
for (const match of source.matchAll(attrPattern)) {
const id = match[1]
if (TEST_ID_FORMAT.test(id)) continue
context.report({
node,
message: `Static data-test-id values must be kebab-case. Invalid id: "${id}".`
})
return
}
if (invalidId === null) return
context.report({
node,
message: `Static data-test-id values must be kebab-case. Invalid id: "${invalidId}".`
})
}
}
}
@ -406,6 +625,22 @@ const noRawTestIdSelectorsInTests = {
}
}
function isGeneratedTestIdLiteral(value) {
if (typeof value !== 'string') return false
const toolbarValue = value.startsWith('mobile-toolbar-')
? value.slice('mobile-'.length)
: value
return (
toolbarValue.startsWith('toolbar-tool-') ||
toolbarValue.startsWith('toolbar-flyout-') ||
toolbarValue.startsWith('toolbar-flyout-item-') ||
value === 'variables-add-float' ||
value === 'variables-add-string' ||
value === 'variables-add-boolean' ||
value.startsWith('acp-permission-option-')
)
}
const noGeneratedTestIdLiterals = {
meta: {
docs: {
@ -418,18 +653,31 @@ const noGeneratedTestIdLiterals = {
if (file.endsWith('/tests/helpers/test-ids.ts')) return {}
if (!file.includes('/src/') && !file.includes('/tests/')) return {}
function report(node) {
context.report({
node,
message:
'Use shared generated test-id helpers instead of hand-written toolbar/variable/permission id literals.'
})
}
return {
Program(node) {
const source = context.sourceCode.getText()
const match = source.match(
/["'`](?:mobile-)?toolbar-(?:tool|flyout|flyout-item)-|["'`]variables-add-(?:float|string|boolean)["'`]|["'`]acp-permission-option-/
)
if (!match) return
context.report({
node,
message:
'Use shared generated test-id helpers instead of hand-written toolbar/variable/permission id literals.'
if (!file.endsWith('.vue')) return
const template = vueTemplateAst(context.sourceCode.getText(), file)
if (!template) return
let hasGeneratedTemplateId = false
walkVueTemplateAst(template, (templateNode) => {
if (hasGeneratedTemplateId || !isStaticVueAttribute(templateNode, 'data-test-id')) return
hasGeneratedTemplateId = isGeneratedTestIdLiteral(templateNode.value?.content)
})
if (hasGeneratedTemplateId) report(node)
},
Literal(node) {
if (isGeneratedTestIdLiteral(node.value)) report(node)
},
TemplateElement(node) {
if (isGeneratedTestIdLiteral(node.value?.raw)) report(node)
}
}
}
@ -631,14 +879,16 @@ const noHandRolledColor = {
return {
TemplateLiteral(node) {
const raw = context.sourceCode.getText(node)
if (/rgba?\s*\(/.test(raw)) {
context.report({
node,
message:
'Use colorToCSS() or colorToHex() from color.ts instead of hand-rolled rgba()/rgb() strings.'
})
}
const hasHandRolledRgb = node.quasis?.some((quasi) => {
const raw = quasi.value?.raw
return typeof raw === 'string' && (raw.includes('rgb(') || raw.includes('rgba('))
})
if (!hasHandRolledRgb) return
context.report({
node,
message:
'Use colorToCSS() or colorToHex() from color.ts instead of hand-rolled rgba()/rgb() strings.'
})
}
}
}
@ -1837,6 +2087,9 @@ const plugin = {
'no-vue-style-blocks': noVueStyleBlocks,
'no-native-title-attributes-in-vue': noNativeTitleAttributesInVue,
'no-hardcoded-tip-labels-in-vue': noHardcodedTipLabelsInVue,
'no-raw-svg-in-app-vue-templates': noRawSvgInAppVueTemplates,
'no-ui-helper-calls-in-vue-templates': noUiHelperCallsInVueTemplates,
'no-large-property-section-components': noLargePropertySectionComponents,
'no-raw-test-id-string-props': noRawTestIdStringProps,
'no-dynamic-data-test-id-in-vue': noDynamicDataTestIdInVue,
'no-test-id-helper-bind-in-vue': noTestIdHelperBindInVue,

View file

@ -151,6 +151,9 @@
"open-pencil/no-vue-style-blocks": "error",
"open-pencil/no-native-title-attributes-in-vue": "error",
"open-pencil/no-hardcoded-tip-labels-in-vue": "error",
"open-pencil/no-raw-svg-in-app-vue-templates": "error",
"open-pencil/no-ui-helper-calls-in-vue-templates": "error",
"open-pencil/no-large-property-section-components": "error",
"open-pencil/no-raw-test-id-string-props": "error",
"open-pencil/no-dynamic-data-test-id-in-vue": "error",
"open-pencil/no-test-id-helper-bind-in-vue": "error",