Skip to content

fix(lint): enforce explicit JSON utility extensions - #1432

Open
RezoanurRahman wants to merge 2 commits into
cuttle-cards:mainfrom
RezoanurRahman:autopilot/issue-1429
Open

RezoanurRahman wants to merge 2 commits into
cuttle-cards:mainfrom
RezoanurRahman:autopilot/issue-1429

Conversation

@RezoanurRahman

Copy link
Copy Markdown

Closes #1429

Summary

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.

Changed files

  • eslint.config.js

Verification

  • npm ci --ignore-scripts --no-audit --no-fund passed
  • npm run test:unit passed
  • npm run lint passed

Code was written with Codex, reviewed and guided by me.

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 cuttle-cards#1429
@RezoanurRahman
RezoanurRahman marked this pull request as ready for review October 8, 2026 14:26

@itsalaidbacklife itsalaidbacklife left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for picking this up — the selector works and does exactly what #1429 described.

I want to revisit the one limitation I flagged in the issue, though, because I think I was wrong about it. I wrote that the JSON basenames have to be hard-coded "because ESLint can't tell from the source whether utils/Foo resolves to .js or .json." That's true of the AST, but a rule isn't limited to the AST — it can resolve the specifier against the filesystem the same way Node does. That removes the hand-maintained list entirely.

I prototyped it and it holds up. Inline plugin in eslint.config.js, no new dependency:

const aliases = { _: __dirname, '@': path.join(__dirname, 'src') };

const resolveSpecifier = (fromDir, spec) => {
  const [ head, ...rest ] = spec.split('/');
  if (Object.hasOwn(aliases, head)) {
    return path.join(aliases[head], ...rest);
  }
  return spec.startsWith('.') ? path.resolve(fromDir, spec) : null;
};

const isJsonCache = new Map();
const resolvesToJson = (target) => {
  if (!isJsonCache.has(target)) {
    isJsonCache.set(target, !fs.existsSync(`${target}.js`) && fs.existsSync(`${target}.json`));
  }
  return isJsonCache.get(target);
};

…wrapped in a rule that visits require(), import, dynamic import(), and re-exports, skipping any specifier that already carries an extension and any bare package name. Full diff at the bottom.

What this buys over the current approach

this PR filesystem resolution
New JSON util added must edit the regex works automatically
JSON file outside utils/ not covered covered
Scope api/** only all 361 linted files
Alias form _/utils/GameStatus not matched matched
Autofix no yes

That fourth row is the one that changed my mind. The frontend imports these enums through the _ alias in eight places — src/router.js:4, src/routes/home/HomeView.vue:165, src/stores/gameList.js:4, and others — and a utils\/(…)$ selector scoped to api/** can't see any of them. The convention is repo-wide but the rule as written guards one corner of it.

Verification

Probe file with the three cases from the issue plus the alias and re-export forms:

  • Flagged: require('../../utils/GameStatus'), import … from '_/utils/GameStatus', '../utils/Transitions', export { default as GamePhase } from '_/utils/GamePhase'
  • Not flagged: anything already .json, plus rulesVersion, game-utils, and the bare sails package — i.e. the real .js utils the issue worried a blanket rule would catch
  • --fix rewrote all four correctly
  • npm run lint: 361 files, 0 errors, ~1.3s — no measurable cost from the existsSync calls

Two caveats, both smaller surfaces than the eight-name list:

  1. The alias map duplicates vite.config.mjs:53. A CJS config can't require an .mjs, so the two entries are copied. If the aliases ever change this needs a matching edit — worth a comment pointing at the vite config.
  2. A specifier like ./some.config whose target is some.config.json is skipped, because path.extname reads .config as an extension. No such file exists today.

Happy either way on how this lands — you take it into this PR, or merge yours as-is and I open a follow-up. Your call, it's your PR. If you do take it, the message string and (see #1397) reference are lifted straight from your version.

Full diff against eslint.config.js
diff --git a/eslint.config.js b/eslint.config.js
index 7958cf95..5a79ca07 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -1,3 +1,5 @@
+const fs = require('fs');
+const path = require('path');
 const js = require('@eslint/js');
 const globals = require('globals');
 const vue = require('eslint-plugin-vue');
@@ -50,6 +52,67 @@ const browserFiles = [
   'vitest.config.mjs',
 ];
 
+// ESLint can't tell from the source whether `utils/Foo` is a .js or a .json file, so instead of
+// hard-coding the enum names we resolve each specifier against the filesystem. Anything that is
+// genuinely JSON on disk must say so in the specifier, or the TS service can't resolve it (#1397).
+const aliases = { _: __dirname, '@': path.join(__dirname, 'src') };
+
+const resolveSpecifier = (fromDir, spec) => {
+  const [ head, ...rest ] = spec.split('/');
+  if (Object.hasOwn(aliases, head)) {
+    return path.join(aliases[head], ...rest);
+  }
+  return spec.startsWith('.') ? path.resolve(fromDir, spec) : null;
+};
+
+const isJsonCache = new Map();
+const resolvesToJson = (target) => {
+  if (!isJsonCache.has(target)) {
+    isJsonCache.set(target, !fs.existsSync(`${target}.js`) && fs.existsSync(`${target}.json`));
+  }
+  return isJsonCache.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' || path.extname(node.value) !== '') {
+            return;
+          }
+          const target = resolveSpecifier(fromDir, node.value);
+          if (!target || !resolvesToJson(target)) {
+            return;
+          }
+          context.report({
+            node,
+            messageId: 'missing',
+            data: { spec: node.value },
+            fix: (fixer) => fixer.replaceText(node, `'${node.value}.json'`),
+          });
+        };
+        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 +129,7 @@ module.exports = [
       vuetify,
       prettier,
       'no-only-tests': noOnlyTests,
+      'json-extensions': jsonExtensions,
     },
     languageOptions: {
       ecmaVersion: 'latest',
@@ -73,6 +137,7 @@ module.exports = [
       globals: nodeGlobals,
     },
     rules: {
+      'json-extensions/explicit-json-extension': 'error',
       'eol-last': [ 'error', 'always' ],
       'max-len': [
         'warn',

@RezoanurRahman

Copy link
Copy Markdown
Author

Thanks for the prototype and for revisiting the limitation. I incorporated the filesystem-resolution approach in commit 1c6f727.

The rule now resolves relative paths and the Vite _ and @ aliases with Node’s resolver. That removes the hard-coded basename list, catches JSON imports across linted files, and leaves extensionless JavaScript imports alone. It covers require(), imports, and re-exports, with an autofix that preserves the original quotes.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[DevEx]: Nothing enforces the explicit .json require convention from #1397

2 participants