From 1aaf37cfff00f7b344bd3392a0aefdd7687d47f9 Mon Sep 17 00:00:00 2001 From: Steven Sacks Date: Fri, 26 Jun 2026 03:36:41 +0900 Subject: [PATCH] feat(base): flag null-returning JSX ternaries via no-restricted-syntax Add two `no-restricted-syntax` selectors that flag `cond ? : null` and `cond ? null : `, pointing to the boolean-guarded `&&` form. Flag-only (no autofix): the safe rewrite needs `!!` for numeric guards and `: undefined` for `??`-fed values, so a human applies it. Consolidate all no-restricted-syntax selectors into one `gaia/no-restricted-syntax` block since ESLint merges that rule key by replacement, not concatenation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/no-null-render-ternary.md | 24 ++++++++++++ src/configs/base.ts | 56 +++++++++++++++++++++------- 2 files changed, 66 insertions(+), 14 deletions(-) create mode 100644 .changeset/no-null-render-ternary.md diff --git a/.changeset/no-null-render-ternary.md b/.changeset/no-null-render-ternary.md new file mode 100644 index 0000000..aa2e1f8 --- /dev/null +++ b/.changeset/no-null-render-ternary.md @@ -0,0 +1,24 @@ +--- +"@gaia-react/lint": minor +--- + +Flag rendering `null` from a JSX ternary via `no-restricted-syntax`. + +`cond ? : null` and `cond ? null : ` are now `error`-level. Both +are the boolean-guarded `&&` render guard written the long way; use +`cond && ` instead (coerce a numeric/falsy guard with `!!cond` so the +`0` value can't leak into the output). + +The selectors are flag-only, with no autofix: a blind `? : null` → `&&` rewrite +is unsafe because numeric-`0` guards need `!!`, `??`-fed values need +`: undefined`, and `||`-guards need per-operand coercion. A human applies the +fix the selector points to. + +Both selectors only match a `JSXElement`/`JSXFragment` branch, so they are +inert outside `.tsx`/`.jsx`. All `no-restricted-syntax` selectors are +consolidated into one `gaia/no-restricted-syntax` config object, because ESLint +flat config merges that rule key by replacement (last match wins), not +concatenation. + +A consumer with a `cond ? : null` ternary will see a NEW lint failure on +upgrade. That is the intent: convert it to `&&`. diff --git a/src/configs/base.ts b/src/configs/base.ts index 26666ca..b15ac20 100644 --- a/src/configs/base.ts +++ b/src/configs/base.ts @@ -275,32 +275,59 @@ const preferArrowFunctionsConfig: Linter.Config[] = [ }, }, { - // `prefer-arrow-functions` has a hardcoded exemption for named - // default-exported declarations (`guard.js:hasNameAndIsExportedAsDefaultExport`), - // so `export default function Foo() {}` slips through. This selector closes - // that gap. Convert to `const Foo = () => {}; export default Foo;` instead. - // `.d.ts` is ignored because ambient `export default function …(): T;` - // declarations have no body to convert. + files: ['**/*.d.ts'], + name: 'ts-definition-files/prefer-arrow-off', + rules: { + 'prefer-arrow-functions/prefer-arrow-functions': 'off', + }, + }, +]; + +/** + * All `no-restricted-syntax` selectors live in one config object. ESLint flat + * config merges this rule key by replacement (last match wins), not + * concatenation, so a second block matching the same files would silently + * clobber these selectors. Each selector carries its own message. + * + * `.d.ts` is ignored: ambient `export default function …(): T;` declarations + * have no body to convert, and type-definition files contain no render code. + */ +const restrictedSyntaxConfig: Linter.Config[] = [ + { ignores: ['**/*.d.ts'], - name: 'prefer-arrow/no-default-exported-function', + name: 'gaia/no-restricted-syntax', rules: { 'no-restricted-syntax': [ 'error', { + // `prefer-arrow-functions` has a hardcoded exemption for named + // default-exported declarations + // (`guard.js:hasNameAndIsExportedAsDefaultExport`), so + // `export default function Foo() {}` slips through. This selector + // closes that gap. message: 'Use `const Name = () => {}; export default Name;` instead. The prefer-arrow-functions plugin exempts named default-exported declarations upstream.', selector: 'ExportDefaultDeclaration > FunctionDeclaration', }, + { + // GAIA never renders `null`. `cond ? : null` is the + // numeric-0-safe `&&` guard written the long way. Both JSX-null + // ternary selectors only match a JSXElement/JSXFragment branch, so + // they are inert outside `.tsx`/`.jsx`. + message: + 'Do not render `null` from a ternary. Use a boolean-guarded `&&`: `cond && ` (coerce a numeric/falsy guard with `!!cond`).', + selector: + "ConditionalExpression[alternate.raw='null']:matches([consequent.type='JSXElement'], [consequent.type='JSXFragment'])", + }, + { + message: + 'Do not render `null` from a ternary. Invert the condition and use `&&`: `!cond && `.', + selector: + "ConditionalExpression[consequent.raw='null']:matches([alternate.type='JSXElement'], [alternate.type='JSXFragment'])", + }, ], }, }, - { - files: ['**/*.d.ts'], - name: 'ts-definition-files/prefer-arrow-off', - rules: { - 'prefer-arrow-functions/prefer-arrow-functions': 'off', - }, - }, ]; const lodashUnderscoreConfig: Linter.Config[] = [ @@ -324,5 +351,6 @@ export const buildBase = (sourceDir: string): Linter.Config[] => [ ...buildImportXConfig(sourceDir), ...eslintCommentsConfig, ...preferArrowFunctionsConfig, + ...restrictedSyntaxConfig, ...lodashUnderscoreConfig, ];