diff --git a/eslint.config.js b/eslint.config.js index 7958cf958..ba4e0d544 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,3 +1,4 @@ +const path = require('path'); const js = require('@eslint/js'); const globals = require('globals'); const vue = require('eslint-plugin-vue'); @@ -50,6 +51,72 @@ const browserFiles = [ 'vitest.config.mjs', ]; +// Keep these aliases in sync with vite.config.mjs. ESLint's CommonJS config cannot import it. +const aliases = { _: __dirname, '@': path.join(__dirname, 'src') }; + +const resolveSpecifier = (fromDir, spec) => { + const alias = Object.keys(aliases).find((prefix) => spec === prefix || spec.startsWith(`${prefix}/`)); + if (alias) { + return path.resolve(aliases[alias], spec.slice(alias.length + (spec === alias ? 0 : 1))); + } + return spec.startsWith('.') ? path.resolve(fromDir, spec) : null; +}; + +const resolvedJsonCache = new Map(); +const resolvesToJson = (target) => { + if (!resolvedJsonCache.has(target)) { + try { + resolvedJsonCache.set(target, require.resolve(target).endsWith('.json')); + } catch { + resolvedJsonCache.set(target, false); + } + } + return resolvedJsonCache.get(target); +}; + +const jsonExtensions = { + rules: { + 'explicit-json-extension': { + meta: { + type: 'problem', + fixable: 'code', + schema: [], + messages: { + missing: `'{{spec}}' resolves to a JSON file; write '{{spec}}.json' so the editor can resolve it (see #1397).`, + }, + }, + create(context) { + const fromDir = path.dirname(context.filename); + const check = (node) => { + if (node?.type !== 'Literal' || typeof node.value !== 'string' || node.value.endsWith('.json')) { + return; + } + const target = resolveSpecifier(fromDir, node.value); + if (!target || !resolvesToJson(target)) { + return; + } + context.report({ + node, + messageId: 'missing', + data: { spec: node.value }, + fix: (fixer) => { + const raw = context.sourceCode.getText(node); + return fixer.replaceText(node, `${raw.slice(0, -1)}.json${raw.slice(-1)}`); + }, + }); + }; + return { + 'CallExpression[callee.name="require"]': (node) => check(node.arguments[0]), + ImportDeclaration: (node) => check(node.source), + ImportExpression: (node) => check(node.source), + ExportNamedDeclaration: (node) => check(node.source), + ExportAllDeclaration: (node) => check(node.source), + }; + }, + }, + }, +}; + module.exports = [ { // The worktree dirs are git-ignored, but that doesn't stop eslint walking into them: each @@ -66,6 +133,7 @@ module.exports = [ vuetify, prettier, 'no-only-tests': noOnlyTests, + 'json-extensions': jsonExtensions, }, languageOptions: { ecmaVersion: 'latest', @@ -73,6 +141,7 @@ module.exports = [ globals: nodeGlobals, }, rules: { + 'json-extensions/explicit-json-extension': 'error', 'eol-last': [ 'error', 'always' ], 'max-len': [ 'warn',