From 823afdd95b1611e035bf164ef82dc0bd462a210b Mon Sep 17 00:00:00 2001 From: Kazi Rezoanur Rahman Date: Thu, 8 Oct 2026 14:13:10 +0000 Subject: [PATCH 1/2] fix(lint): enforce explicit JSON utility extensions Adds an API-scoped ESLint rule for extensionless JSON utility requires. Preserves extensionless imports for JavaScript utilities. No current API offenders required changes. `npm run lint` and `npm run test:unit` pass. Closes #1429 --- eslint.config.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/eslint.config.js b/eslint.config.js index 7958cf958..b84dcd027 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -40,6 +40,7 @@ const sailsGlobals = { const clientFiles = '**/client/**/*.{j,t}s?(x)'; const storybookFiles = '**/(.storybook|stories)/**/*.{j,t}s?(x)'; const apiFiles = '**/api/**/*.{j,t}s?(x)'; +const jsonUtils = 'DeckIds|gameActivity|GamePhase|GameStatus|MoveType|TargetType|ThemeColors|Transitions'; const unitTestFiles = '**/tests/unit/**/*.{j,t}s?(x)'; const e2eTestFiles = '**/tests/e2e/**/*.{j,t}s?(x)'; const browserFiles = [ @@ -195,6 +196,13 @@ module.exports = [ rules: { 'no-undef': 'error', 'no-prototype-builtins': 'error', + 'no-restricted-syntax': [ + 'error', + { + selector: `CallExpression[callee.name='require'] > Literal[value=/utils\\u002f(${jsonUtils})$/]`, + message: 'Require JSON utils with an explicit .json extension so the editor can resolve them (see #1397).', + }, + ], }, }, { From 1c6f7271c650ccc26d590700925f0eaba5b3b4d8 Mon Sep 17 00:00:00 2001 From: Kazi Rezoanur Rahman Date: Mon, 12 Oct 2026 21:26:26 +0200 Subject: [PATCH 2/2] fix(lint): resolve JSON import extensions --- eslint.config.js | 77 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index b84dcd027..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'); @@ -40,7 +41,6 @@ const sailsGlobals = { const clientFiles = '**/client/**/*.{j,t}s?(x)'; const storybookFiles = '**/(.storybook|stories)/**/*.{j,t}s?(x)'; const apiFiles = '**/api/**/*.{j,t}s?(x)'; -const jsonUtils = 'DeckIds|gameActivity|GamePhase|GameStatus|MoveType|TargetType|ThemeColors|Transitions'; const unitTestFiles = '**/tests/unit/**/*.{j,t}s?(x)'; const e2eTestFiles = '**/tests/e2e/**/*.{j,t}s?(x)'; const browserFiles = [ @@ -51,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 @@ -67,6 +133,7 @@ module.exports = [ vuetify, prettier, 'no-only-tests': noOnlyTests, + 'json-extensions': jsonExtensions, }, languageOptions: { ecmaVersion: 'latest', @@ -74,6 +141,7 @@ module.exports = [ globals: nodeGlobals, }, rules: { + 'json-extensions/explicit-json-extension': 'error', 'eol-last': [ 'error', 'always' ], 'max-len': [ 'warn', @@ -196,13 +264,6 @@ module.exports = [ rules: { 'no-undef': 'error', 'no-prototype-builtins': 'error', - 'no-restricted-syntax': [ - 'error', - { - selector: `CallExpression[callee.name='require'] > Literal[value=/utils\\u002f(${jsonUtils})$/]`, - message: 'Require JSON utils with an explicit .json extension so the editor can resolve them (see #1397).', - }, - ], }, }, {