// Custom oxlint JS plugin for the tldraw monorepo. // // Replaces all local ESLint rules and several ESLint-only rules that oxlint // doesn't have built-in equivalents for. Each rule uses the standard // ESLint-compatible create(context) API so the plugin works in both oxlint // and ESLint if ever needed. import { existsSync, readdirSync, readFileSync } from 'fs' import { builtinModules } from 'module' import { dirname, join, relative, resolve } from 'path' import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) const REPO_ROOT = join(__dirname, '../../..') const TIPTAP_IMPORT_PREFIX = '@tiptap/' const CLAUDE_AGENT_ROOTS = ['apps', 'packages', 'templates'] const CLAUDE_FILE_NAME = 'CLAUDE.md' const AGENTS_FILE_NAME = 'AGENTS.md' const CLAUDE_AGENT_REFERENCE = '@AGENTS.md' const SKIPPED_DIR_NAMES = new Set([ 'node_modules', 'out', 'dist', 'dist-cjs', 'dist-esm', '.lazy', '.next', '.wrangler', '.vercel', 'coverage', ]) const NODE_BUILTINS = new Set(builtinModules) const packageJsonCache = new Map() let cachedClaudeAgentViolations = null let hasReportedClaudeAgentViolations = false // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function normalizePath(filePath) { return filePath.split('\\').join('/') } function shouldSkipDirectory(entryName) { return SKIPPED_DIR_NAMES.has(entryName) } /** * Walk up from a file to the package.json that owns it, stopping at the repo * root. Returns null for files outside any workspace. */ function findOwningPackage(filePath) { let dir = dirname(resolve(filePath)) const root = resolve(REPO_ROOT) while (dir.startsWith(root)) { if (packageJsonCache.has(dir)) { const cached = packageJsonCache.get(dir) if (cached) return cached } else { const packageJsonPath = join(dir, 'package.json') if (existsSync(packageJsonPath)) { let owner = null try { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) owner = { name: packageJson.name, declared: new Set([ ...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.peerDependencies ?? {}), ...Object.keys(packageJson.optionalDependencies ?? {}), ...Object.keys(packageJson.devDependencies ?? {}), ]), } } catch { // An unreadable package.json is someone else's error to report. } packageJsonCache.set(dir, owner) if (owner) return owner } else { packageJsonCache.set(dir, null) } } const parent = dirname(dir) if (parent === dir) break dir = parent } return null } /** Strip a subpath off an import specifier, leaving the bare package name. */ function getImportedPackageName(specifier) { if (!specifier || specifier.startsWith('.') || specifier.startsWith('/')) return null // `@/components` is a tsconfig path alias, not a scoped package. if (specifier.startsWith('@/')) return null // `node:fs`, `cloudflare:workers`, `data:`, and friends are never packages. if (specifier.includes(':')) return null if (NODE_BUILTINS.has(specifier.split('/')[0])) return null const parts = specifier.split('/') return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] } function collectClaudeAgentViolations() { const violations = [] for (const rootDirName of CLAUDE_AGENT_ROOTS) { collectClaudeAgentViolationsInDirectory(join(REPO_ROOT, rootDirName), violations) } return violations } function collectClaudeAgentViolationsInDirectory(dir, violations) { if (!existsSync(dir)) return for (const entry of readdirSync(dir, { withFileTypes: true })) { const fullPath = join(dir, entry.name) if (entry.isDirectory()) { if (shouldSkipDirectory(entry.name)) continue collectClaudeAgentViolationsInDirectory(fullPath, violations) continue } if (!entry.isFile() || entry.name.toLowerCase() !== CLAUDE_FILE_NAME.toLowerCase()) { continue } const relativeClaudePath = normalizePath(relative(REPO_ROOT, fullPath)) const claudeContents = readFileSync(fullPath, 'utf8') const agentsPath = join(dir, AGENTS_FILE_NAME) if (!existsSync(agentsPath)) { violations.push({ claudePath: relativeClaudePath, messageId: 'missingAgents', }) } if (!claudeContents.includes(CLAUDE_AGENT_REFERENCE)) { violations.push({ claudePath: relativeClaudePath, messageId: 'missingReference', }) } } } function getClaudeAgentViolations() { if (cachedClaudeAgentViolations === null) { cachedClaudeAgentViolations = collectClaudeAgentViolations() } return cachedClaudeAgentViolations } function extractParamNames(comment) { const params = [] const regex = /@param\s+(?:\{[^}]*\}\s+)?(\w+)/g let match while ((match = regex.exec(comment)) !== null) { params.push(match[1]) } return params } function getParamName(param) { switch (param.type) { case 'Identifier': return param.name case 'AssignmentPattern': if (param.left.type === 'Identifier') return param.left.name return getParamName(param.left) case 'RestElement': if (param.argument.type === 'Identifier') return param.argument.name return null default: return null } } function isComponentName(node) { return node.type === 'Identifier' && /^[A-Z]/.test(node.name) } function findTopLevelParent(node) { let current = node while (current.parent && current.parent.type !== 'Program') { current = current.parent } return current } function unwrapExpression(node) { let current = node while ( current && (current.type === 'ChainExpression' || current.type === 'ParenthesizedExpression' || current.type === 'TSAsExpression' || current.type === 'TSSatisfiesExpression' || current.type === 'TSNonNullExpression' || current.type === 'TSTypeAssertion') ) { current = current.expression } return current } function getStaticPropertyName(node) { if (node.type === 'Identifier') return node.name if (node.type === 'Literal' && typeof node.value === 'string') return node.value return null } function isPath2DConstructor(callee) { const unwrapped = unwrapExpression(callee) if (unwrapped?.type === 'Identifier') return unwrapped.name === 'Path2D' if (unwrapped?.type !== 'MemberExpression') return false const object = unwrapExpression(unwrapped.object) const propertyName = getStaticPropertyName(unwrapped.property) return ( propertyName === 'Path2D' && object?.type === 'Identifier' && (object.name === 'globalThis' || object.name === 'window') ) } function isNewPath2D(node) { const unwrapped = unwrapExpression(node) return unwrapped?.type === 'NewExpression' && isPath2DConstructor(unwrapped.callee) } function isPath2DTypeAnnotation(node) { const annotation = node?.type === 'TSTypeAnnotation' ? node.typeAnnotation : node if (!annotation) return false if (annotation.type === 'TSTypeReference') { return annotation.typeName?.type === 'Identifier' && annotation.typeName.name === 'Path2D' } if (annotation.type === 'TSUnionType') { return annotation.types.some(isPath2DTypeAnnotation) } return false } function getAssignableIdentifier(node) { const unwrapped = unwrapExpression(node) switch (unwrapped?.type) { case 'Identifier': return unwrapped case 'AssignmentPattern': return getAssignableIdentifier(unwrapped.left) case 'RestElement': return getAssignableIdentifier(unwrapped.argument) default: return null } } // --------------------------------------------------------------------------- // Rules ported from the legacy ESLint plugin // --------------------------------------------------------------------------- // Packages that exist only as their `@types/*` declaration: type-only specs (`mdast`, // `topojson-specification`) and modules the host provides at runtime (`vscode`). Kept as an explicit // list rather than honoring any declared `@types/*`, so `@types/react` can't stand in for an // undeclared `react`. A new type-only import fails lint until it is added here. const TYPES_ONLY_PACKAGES = new Set(['mdast', 'topojson-specification', 'vscode']) const rules = { 'no-whilst': { meta: { messages: { noWhilst: 'Use "while" instead of "whilst" to maintain American English style, sorry.', }, type: 'problem', schema: [], fixable: 'code', }, create(context) { if (context.filename.includes('tldraw-plugin')) return {} function check(node, raw, value) { if (value.includes('whilst')) { context.report({ messageId: 'noWhilst', node, fix: (fixer) => fixer.replaceText(node, raw.replace(/whilst/g, 'while')), }) } } return { Literal(node) { if (typeof node.value === 'string') check(node, node.raw, node.value) }, JSXText(node) { check(node, node.value, node.value) }, TemplateElement(node) { check(node, node.value.raw, node.value.raw) }, } }, }, 'claude-agents-pairing': { meta: { messages: { missingAgents: '`{{claudePath}}` must have a colocated `AGENTS.md` so the instructions work across coding tools.', missingReference: '`{{claudePath}}` must reference the colocated `AGENTS.md` using `@AGENTS.md`.', }, type: 'problem', schema: [], }, create(context) { return { Program(node) { if (hasReportedClaudeAgentViolations) return hasReportedClaudeAgentViolations = true for (const violation of getClaudeAgentViolations()) { context.report({ node, messageId: violation.messageId, data: { claudePath: violation.claudePath }, }) } }, } }, }, 'no-tiptap-default-import': { meta: { messages: { noDefault: 'Use named imports when importing from {{module}} to avoid CommonJS interop issues.', }, type: 'problem', schema: [], }, create(context) { return { ImportDeclaration(node) { const source = node.source.value if (typeof source !== 'string' || !source.startsWith(TIPTAP_IMPORT_PREFIX)) return for (const spec of node.specifiers) { if ( spec.type === 'ImportDefaultSpecifier' || spec.type === 'ImportNamespaceSpecifier' ) { context.report({ node: spec, messageId: 'noDefault', data: { module: source }, }) } } }, } }, }, 'no-export-star': { meta: { messages: { named: 'Use specific named exports instead of export *', }, type: 'suggestion', schema: [], }, create(context) { return { ExportAllDeclaration(node) { if (node.exported !== null) return context.report({ messageId: 'named', node }) }, } }, }, 'no-internal-imports': { meta: { messages: { internal: "Don't import from internal tldraw source ({{path}})", }, type: 'problem', schema: [], }, create(context) { return { ImportDeclaration(node) { const importPath = node.source.value const parts = importPath.split('/') switch (parts[0]) { case 'tldraw': if (parts.length === 1) return if (importPath.endsWith('.css')) return break case '@tldraw': if (parts.length === 2) return if (importPath.endsWith('.css')) return if (parts[1] === 'assets' && parts.length === 3) return break default: return } context.report({ messageId: 'internal', node: node.source, data: { path: importPath }, }) }, } }, }, 'no-undeclared-dependencies': { meta: { messages: { undeclared: "'{{name}}' is imported here but isn't declared in {{owner}}'s package.json. Yarn's hoisted node_modules resolves it anyway, but package managers with strict isolation (pnpm, Yarn PnP) can't.", }, type: 'problem', schema: [], }, create(context) { const owner = findOwningPackage(context.filename) if (!owner) return {} function check(node, specifier) { if (typeof specifier !== 'string') return const name = getImportedPackageName(specifier) if (!name || name === owner.name) return if (owner.declared.has(name)) return if (TYPES_ONLY_PACKAGES.has(name) && owner.declared.has(`@types/${name}`)) return context.report({ node, messageId: 'undeclared', data: { name, owner: owner.name } }) } return { ImportDeclaration(node) { check(node.source, node.source.value) }, ExportNamedDeclaration(node) { if (node.source) check(node.source, node.source.value) }, ExportAllDeclaration(node) { check(node.source, node.source.value) }, ImportExpression(node) { if (node.source.type === 'Literal') check(node.source, node.source.value) }, CallExpression(node) { if (node.callee.type !== 'Identifier' || node.callee.name !== 'require') return const arg = node.arguments[0] if (arg?.type === 'Literal') check(arg, arg.value) }, } }, }, 'prefer-class-methods': { meta: { messages: { preferMethod: 'Prefer using a method instead of an arrow function property.', }, type: 'problem', schema: [], fixable: 'code', }, create(context) { return { ClassBody(node) { for (const member of node.body) { if ( member.type !== 'PropertyDefinition' || !member.value || member.value.type !== 'ArrowFunctionExpression' ) { continue } const arrow = member.value context.report({ node: member, messageId: 'preferMethod', fix(fixer) { const src = context.sourceCode const name = src.getText(member.key) const params = arrow.params.map((p) => src.getText(p)).join(', ') const async = arrow.async ? 'async ' : '' const typeAnn = member.typeAnnotation ? src.getText(member.typeAnnotation) : '' const body = arrow.body.type === 'BlockStatement' ? src.getText(arrow.body) : `{ return ${src.getText(arrow.body)} }` return fixer.replaceText(member, `${async}${name}(${params})${typeAnn} ${body}`) }, }) } }, } }, }, 'tsdoc-param-matching': { meta: { type: 'problem', schema: [], messages: { paramMismatch: "Parameter '{{ paramName }}' is documented but not present in function definition (in {{ name }}).", paramMissing: "Parameter '{{ paramName }}' is defined but missing from TSDoc @param (in {{ name }}).", }, }, create(context) { function checkParams(node, params, name) { const leadingComments = context.sourceCode.getCommentsBefore(node) let tsDocComment = null for (let i = leadingComments.length - 1; i >= 0; i--) { const comment = leadingComments[i] if ( comment.type === 'Block' && comment.value.includes('* @param') && comment.loc.end.line === node.loc.start.line - 1 ) { tsDocComment = '/*' + comment.value + '*/' break } } if (!tsDocComment) return const docParams = extractParamNames(tsDocComment) if (docParams.length === 0) return const funcParams = params.map(getParamName).filter(Boolean) for (const param of docParams) { if (!funcParams.includes(param) && !funcParams.includes(`_${param}`)) { context.report({ node, messageId: 'paramMismatch', data: { paramName: param, name }, }) } } const adjustedDocParams = new Set(docParams.flatMap((p) => [p, `_${p}`])) for (const param of funcParams) { if (!adjustedDocParams.has(param)) { context.report({ node, messageId: 'paramMissing', data: { paramName: param, name }, }) } } } return { FunctionDeclaration(node) { checkParams(node, node.params, node.id?.name || 'anonymous function') }, MethodDefinition(node) { if (node.value.type === 'FunctionExpression') { checkParams( node, node.value.params, node.key.type === 'Identifier' ? node.key.name : 'anonymous method' ) } }, TSAbstractMethodDefinition(node) { checkParams( node, node.value.params, node.key.type === 'Identifier' ? node.key.name : 'anonymous method' ) }, Property(node) { if ( (node.value.type === 'FunctionExpression' || node.value.type === 'ArrowFunctionExpression') && node.key.type === 'Identifier' ) { checkParams(node, node.value.params, node.key.name) } }, } }, }, 'tagged-components': { meta: { messages: { untagged: 'This react component should be tagged with @react', }, type: 'problem', schema: [], fixable: 'code', }, create(context) { function checkComponentDeclaration(node) { const declaration = findTopLevelParent(node) const comments = context.sourceCode.getCommentsBefore(declaration) const publicComment = comments.find((c) => c.value.includes('@public')) if (!publicComment) return if (publicComment.value.includes('@react')) return context.report({ messageId: 'untagged', node: publicComment, fix(fixer) { const hasLines = publicComment.value.includes('\n') let replacement if (hasLines) { const lines = publicComment.value.split('\n') const idx = lines.findIndex((l) => l.includes('@public')) if (idx === -1) return null const indent = lines[idx].match(/^\s*/)[0] lines.splice(idx + 1, 0, `${indent}* @react`) replacement = lines.join('\n') } else { replacement = publicComment.value.replace('@public', '@public @react') } return fixer.replaceText(publicComment, `/*${replacement}*/`) }, }) } function checkFunctionExpression(node) { const parent = node.parent if (!parent) return if (parent.type === 'VariableDeclarator' && isComponentName(parent.id)) { checkComponentDeclaration(parent) } if (parent.type === 'CallExpression') { const callee = parent.callee const grandparent = parent.parent if (!grandparent) return const isMemoOrForwardRef = (callee.type === 'Identifier' && (callee.name === 'memo' || callee.name === 'forwardRef')) || (callee.type === 'MemberExpression' && callee.property.type === 'Identifier' && (callee.property.name === 'memo' || callee.property.name === 'forwardRef')) if ( isMemoOrForwardRef && grandparent.type === 'VariableDeclarator' && isComponentName(grandparent.id) ) { checkComponentDeclaration(grandparent) } } } return { FunctionDeclaration(node) { if (node.id && isComponentName(node.id)) checkComponentDeclaration(node) }, FunctionExpression(node) { checkFunctionExpression(node) }, ArrowFunctionExpression(node) { checkFunctionExpression(node) }, } }, }, // --------------------------------------------------------------------------- // Rules replacing no-restricted-syntax selectors // --------------------------------------------------------------------------- 'no-setter-getter': { meta: { messages: { setter: 'Property setters are not allowed', getter: 'Property getters are not allowed', }, type: 'problem', schema: [], }, create(context) { return { MethodDefinition(node) { if (node.kind === 'set') context.report({ node, messageId: 'setter' }) if (node.kind === 'get') context.report({ node, messageId: 'getter' }) }, } }, }, 'no-direct-storage': { meta: { messages: { localStorage: 'Use the getFromLocalStorage/setInLocalStorage helpers instead', sessionStorage: 'Use the getFromSessionStorage/setInSessionStorage helpers instead', }, type: 'problem', schema: [], }, create(context) { return { Identifier(node) { if (node.name === 'localStorage' || node.name === 'sessionStorage') { if (node.parent?.type === 'MemberExpression' && node.parent.object !== node) { return } if (node.parent?.type === 'Property' && node.parent.key === node) return context.report({ node, messageId: node.name }) } }, } }, }, 'no-path2d-roundrect': { meta: { messages: { noRoundRect: 'Avoid `Path2D#roundRect()`; use `Path2D#rect()`, an SVG path string, or manual path commands instead.', }, type: 'problem', schema: [], }, create(context) { const scopes = [] function currentScope() { if (scopes.length === 0) enterScope() return scopes[scopes.length - 1] } function enterScope() { scopes.push({ declared: new Set(), path2d: new Set(), typedPath2d: new Set(), }) } function exitScope() { scopes.pop() } function declareIdentifier(identifier, isPath2D, isTypedPath2D = false) { const scope = currentScope() scope.declared.add(identifier.name) if (isTypedPath2D) { scope.typedPath2d.add(identifier.name) } else { scope.typedPath2d.delete(identifier.name) } if (isPath2D) { scope.path2d.add(identifier.name) } else { scope.path2d.delete(identifier.name) } } function assignIdentifier(identifier, isPath2D) { for (let i = scopes.length - 1; i >= 0; i--) { const scope = scopes[i] if (scope.declared.has(identifier.name)) { if (isPath2D) { scope.path2d.add(identifier.name) } else if (!scope.typedPath2d.has(identifier.name)) { scope.path2d.delete(identifier.name) } return } } declareIdentifier(identifier, isPath2D) } function isPath2DIdentifier(identifier) { for (let i = scopes.length - 1; i >= 0; i--) { const scope = scopes[i] if (!scope.declared.has(identifier.name)) continue return scope.path2d.has(identifier.name) } return false } function declarePattern(pattern, isPath2D) { const identifier = getAssignableIdentifier(pattern) if (identifier) declareIdentifier(identifier, isPath2D, isPath2D) } function declareParam(param) { const identifier = getAssignableIdentifier(param) if (!identifier) return const hasPath2DType = isPath2DTypeAnnotation(identifier.typeAnnotation) declareIdentifier(identifier, hasPath2DType, hasPath2DType) } function enterFunctionScope(node) { enterScope() for (const param of node.params) { declareParam(param) } } function isPath2DTarget(node) { const unwrapped = unwrapExpression(node) if (isNewPath2D(unwrapped)) return true return unwrapped?.type === 'Identifier' && isPath2DIdentifier(unwrapped) } function isRoundRectCall(node) { const callee = unwrapExpression(node.callee) if (callee?.type !== 'MemberExpression') return false if (getStaticPropertyName(callee.property) !== 'roundRect') return false return isPath2DTarget(callee.object) } return { Program() { enterScope() }, 'Program:exit'() { exitScope() }, BlockStatement() { enterScope() }, 'BlockStatement:exit'() { exitScope() }, FunctionDeclaration: enterFunctionScope, 'FunctionDeclaration:exit': exitScope, FunctionExpression: enterFunctionScope, 'FunctionExpression:exit': exitScope, ArrowFunctionExpression: enterFunctionScope, 'ArrowFunctionExpression:exit': exitScope, CatchClause(node) { if (node.param) { declarePattern(node.param, isPath2DTypeAnnotation(node.param.typeAnnotation)) } }, VariableDeclarator(node) { const identifier = getAssignableIdentifier(node.id) if (!identifier) return const hasPath2DType = isPath2DTypeAnnotation(identifier.typeAnnotation) declareIdentifier(identifier, isNewPath2D(node.init) || hasPath2DType, hasPath2DType) }, AssignmentExpression(node) { const identifier = getAssignableIdentifier(node.left) if (!identifier) return assignIdentifier(identifier, isNewPath2D(node.right)) }, CallExpression(node) { if (isRoundRectCall(node)) { const callee = unwrapExpression(node.callee) context.report({ node: callee.property, messageId: 'noRoundRect' }) } }, } }, }, 'no-exported-arrow-const': { meta: { messages: { noArrow: 'Use a function declaration instead of an arrow function here.', }, type: 'problem', schema: [], }, create(context) { return { ExportNamedDeclaration(node) { if ( node.declaration?.type === 'VariableDeclaration' && node.declaration.kind === 'const' ) { for (const declarator of node.declaration.declarations) { if (declarator.init?.type === 'ArrowFunctionExpression') { context.report({ node: declarator, messageId: 'noArrow' }) } } } }, } }, }, 'img-referrer-policy': { meta: { messages: { missing: 'You must pass `referrerPolicy` when creating an .', }, type: 'problem', schema: [], }, create(context) { return { JSXOpeningElement(node) { if (node.name.type !== 'JSXIdentifier' || node.name.name !== 'img') return const hasReferrerPolicy = node.attributes.some( (attr) => attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier' && attr.name.name === 'referrerPolicy' ) if (!hasReferrerPolicy) { context.report({ node, messageId: 'missing' }) } }, } }, }, // --------------------------------------------------------------------------- // Rules replacing ESLint built-in rules not available in oxlint // --------------------------------------------------------------------------- 'no-restricted-properties': { meta: { messages: { restricted: '{{message}}', }, type: 'problem', schema: [ { type: 'array', items: { type: 'object', properties: { object: { type: 'string' }, property: { type: 'string' }, message: { type: 'string' }, }, required: ['object', 'property', 'message'], additionalProperties: false, }, }, ], }, create(context) { const restrictions = context.options[0] || [] return { MemberExpression(node) { if (node.computed) return const objName = node.object.type === 'Identifier' ? node.object.name : null const propName = node.property.type === 'Identifier' ? node.property.name : null if (!objName || !propName) return for (const r of restrictions) { if (r.object === objName && r.property === propName) { context.report({ node, messageId: 'restricted', data: { message: r.message } }) } } }, } }, }, 'jsx-no-literals': { meta: { messages: { noStrings: 'Strings not allowed in JSX — use react-intl', }, type: 'problem', schema: [], }, create(context) { function isJSXChild(node) { const parent = node.parent return parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment') } return { JSXText(node) { if (node.value.trim().length > 0) { context.report({ node, messageId: 'noStrings' }) } }, JSXExpressionContainer(node) { if (!isJSXChild(node)) return if ( node.expression.type === 'Literal' && typeof node.expression.value === 'string' && node.expression.value.trim().length > 0 ) { context.report({ node, messageId: 'noStrings' }) } if (node.expression.type === 'TemplateLiteral') { context.report({ node, messageId: 'noStrings' }) } }, } }, }, 'method-signature-style': { meta: { messages: { method: 'Shorthand method signature is required. Use `{{name}}(...): ...` instead.', }, type: 'problem', schema: [], fixable: 'code', }, create(context) { return { TSPropertySignature(node) { const typeAnn = node.typeAnnotation?.typeAnnotation if (!typeAnn || typeAnn.type !== 'TSFunctionType') return const name = node.key.type === 'Identifier' ? node.key.name : context.sourceCode.getText(node.key) context.report({ node, messageId: 'method', data: { name }, fix(fixer) { const src = context.sourceCode const fnType = typeAnn const params = fnType.params.map((p) => src.getText(p)).join(', ') const typeParams = fnType.typeParameters ? src.getText(fnType.typeParameters) : '' const returnType = fnType.returnType ? src.getText(fnType.returnType) : ': void' const optional = node.optional ? '?' : '' return fixer.replaceText( node, `${name}${optional}${typeParams}(${params})${returnType}` ) }, }) }, } }, }, 'no-focused-tests': { meta: { messages: { focused: 'Focused tests are not allowed — remove .only', }, type: 'problem', schema: [], }, create(context) { const TEST_FNS = new Set(['describe', 'it', 'test', 'suite', 'bench']) return { CallExpression(node) { const callee = node.callee if (callee.type !== 'MemberExpression') return if (callee.property.type !== 'Identifier' || callee.property.name !== 'only') { return } let obj = callee.object while (obj.type === 'MemberExpression') obj = obj.object if (obj.type === 'Identifier' && TEST_FNS.has(obj.name)) { context.report({ node: callee.property, messageId: 'focused' }) } }, } }, }, 'enforce-default-message': { meta: { messages: { missing: '"defaultMessage" is required in message descriptor', literal: '"defaultMessage" must be a string literal', }, type: 'problem', schema: [{ enum: ['literal'] }], }, create(context) { const mode = context.options[0] || 'literal' function checkDescriptor(node, descriptor) { if (!descriptor || descriptor.type !== 'ObjectExpression') return const dmProp = descriptor.properties.find( (p) => p.type === 'Property' && p.key.type === 'Identifier' && p.key.name === 'defaultMessage' ) if (!dmProp) { context.report({ node: descriptor, messageId: 'missing' }) return } if (mode === 'literal' && dmProp.value.type !== 'Literal') { context.report({ node: dmProp.value, messageId: 'literal' }) } } function isFormatMessageCall(callee) { if (callee.type === 'Identifier' && callee.name === 'formatMessage') return true if ( callee.type === 'MemberExpression' && callee.property.type === 'Identifier' && callee.property.name === 'formatMessage' ) { return true } return false } function isDefineCall(callee) { return ( callee.type === 'Identifier' && (callee.name === 'defineMessage' || callee.name === 'defineMessages') ) } return { CallExpression(node) { if (isFormatMessageCall(node.callee)) { checkDescriptor(node, node.arguments[0]) } else if (node.callee.type === 'Identifier' && node.callee.name === 'defineMessage') { checkDescriptor(node, node.arguments[0]) } else if (node.callee.type === 'Identifier' && node.callee.name === 'defineMessages') { const arg = node.arguments[0] if (arg?.type === 'ObjectExpression') { for (const prop of arg.properties) { if (prop.type === 'Property') { checkDescriptor(node, prop.value) } } } } }, JSXOpeningElement(node) { if (node.name.type !== 'JSXIdentifier' || node.name.name !== 'FormattedMessage') { return } const dmAttr = node.attributes.find( (a) => a.type === 'JSXAttribute' && a.name.type === 'JSXIdentifier' && a.name.name === 'defaultMessage' ) if (!dmAttr) { context.report({ node, messageId: 'missing' }) return } if (mode === 'literal') { const val = dmAttr.value if (!val || (val.type !== 'Literal' && val.type !== 'JSXExpressionContainer')) { return } if (val.type === 'JSXExpressionContainer' && val.expression.type !== 'Literal') { context.report({ node: val, messageId: 'literal' }) } } }, } }, }, } // --------------------------------------------------------------------------- // Plugin export // --------------------------------------------------------------------------- const plugin = { meta: { name: 'tldraw' }, rules, } export default plugin