Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
1205 lines
32 KiB
JavaScript
1205 lines
32 KiB
JavaScript
// 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 <img>.',
|
|
},
|
|
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
|