2026-03-09 06:41:28 +00:00
|
|
|
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
|
|
|
|
|
|
2026-03-09 06:48:38 +00:00
|
|
|
const required = props.filter((m) => !m.optional)
|
|
|
|
|
if (required.length < 2) return
|
2026-03-09 06:41:28 +00:00
|
|
|
|
2026-03-09 06:48:38 +00:00
|
|
|
const shape = required
|
2026-03-09 06:41:28 +00:00
|
|
|
.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'.`,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const noMathRandom = {
|
|
|
|
|
meta: {
|
|
|
|
|
docs: {
|
|
|
|
|
description: 'Disallow Math.random() — use crypto.getRandomValues() instead',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
create(context) {
|
|
|
|
|
return {
|
|
|
|
|
CallExpression(node) {
|
|
|
|
|
if (
|
|
|
|
|
node.callee?.type === 'MemberExpression' &&
|
|
|
|
|
node.callee.object?.type === 'Identifier' &&
|
|
|
|
|
node.callee.object.name === 'Math' &&
|
|
|
|
|
node.callee.property?.type === 'Identifier' &&
|
|
|
|
|
node.callee.property.name === 'random'
|
|
|
|
|
) {
|
|
|
|
|
context.report({
|
|
|
|
|
node,
|
|
|
|
|
message: 'Use crypto.getRandomValues() instead of Math.random().',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 07:05:18 +00:00
|
|
|
const noHandRolledColor = {
|
|
|
|
|
meta: {
|
|
|
|
|
docs: {
|
|
|
|
|
description:
|
|
|
|
|
'Disallow hand-rolled color conversions — use helpers from color.ts (colorToCSS, colorToHex, parseColor, etc.)',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
create(context) {
|
|
|
|
|
const file = context.filename ?? context.getFilename?.()
|
|
|
|
|
if (file?.endsWith('color.ts') || file?.endsWith('color.js')) return {}
|
|
|
|
|
|
|
|
|
|
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.",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 06:41:28 +00:00
|
|
|
const plugin = {
|
|
|
|
|
meta: { name: 'open-pencil' },
|
|
|
|
|
rules: {
|
|
|
|
|
'no-inline-named-types': noInlineNamedTypes,
|
|
|
|
|
'no-structuredclone-scene-arrays': noStructuredCloneSceneArrays,
|
|
|
|
|
'no-math-random': noMathRandom,
|
2026-03-09 07:05:18 +00:00
|
|
|
'no-hand-rolled-color': noHandRolledColor,
|
2026-03-09 06:41:28 +00:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default plugin
|