From a864adc1b5d71d458c8b75dc9631bd06b6cb2c9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 21 Jul 2026 20:07:06 -0300 Subject: [PATCH] Add ESLint guard for invalid use server exports and fix plaza staff messages. Catch Next.js invalid-use-server-value at lint time and move shared form-error copy out of plazaStaffFormActions so the use server module only exports async actions. --- eslint-local-rules.mjs | 167 ++++++++++++++++++ eslint.config.mjs | 16 +- .../(app)/pracas/[slug]/editar/formActions.ts | 2 +- .../(app)/pracas/plazaStaffEditMessages.ts | 4 + .../(app)/pracas/plazaStaffFormActions.ts | 4 +- tests/int/eslintConventions.int.spec.ts | 81 +++++---- 6 files changed, 237 insertions(+), 37 deletions(-) create mode 100644 src/app/(campaign)/campanha/(app)/pracas/plazaStaffEditMessages.ts diff --git a/eslint-local-rules.mjs b/eslint-local-rules.mjs index e6045272..660ef57f 100644 --- a/eslint-local-rules.mjs +++ b/eslint-local-rules.mjs @@ -115,10 +115,177 @@ const componentArrowConventions = { }, } +const isFunctionNode = (node) => + node?.type === 'FunctionDeclaration' || + node?.type === 'FunctionExpression' || + node?.type === 'ArrowFunctionExpression' + +const isAsyncFunctionNode = (node) => isFunctionNode(node) && node.async === true + +const isUseServerDirectiveStatement = (statement) => + statement?.type === 'ExpressionStatement' && + statement.expression?.type === 'Literal' && + statement.expression.value === 'use server' + +const hasTopLevelUseServer = (program) => + Array.isArray(program.body) && program.body.some(isUseServerDirectiveStatement) + +const isTypeOnlyNamedExport = (node) => { + if (node.exportKind === 'type') return true + + const declaration = node.declaration + if (!declaration) return false + + return ( + declaration.type === 'TSTypeAliasDeclaration' || + declaration.type === 'TSInterfaceDeclaration' || + declaration.type === 'TSModuleDeclaration' + ) +} + +const resolveLocalAsyncBinding = (program, name) => { + for (const statement of program.body) { + if (statement.type === 'FunctionDeclaration' && statement.id?.name === name) { + return isAsyncFunctionNode(statement) + } + + if (statement.type === 'VariableDeclaration') { + for (const declarator of statement.declarations) { + if (declarator.id?.type === 'Identifier' && declarator.id.name === name) { + return isAsyncFunctionNode(declarator.init) + } + } + } + } + + return false +} + +/** + * Next.js rejects any non-async-function export from a top-level `'use server'` + * module (https://nextjs.org/docs/messages/invalid-use-server-value). Inspired by + * eslint-plugin-next-recommended's export-server-actions-only, kept local because + * that package is unmaintained and ESLint-8-era. + */ +const useServerAsyncExports = { + meta: { + type: 'problem', + docs: { + description: + 'Require top-level `use server` modules to export only async functions (plus type-only exports)', + }, + schema: [], + messages: { + nonAsyncExport: + 'A `"use server"` file can only export async functions (found non-async export). Move constants, types-as-values, and helpers to a separate module. See https://nextjs.org/docs/messages/invalid-use-server-value', + reexport: + 'A `"use server"` file cannot re-export values (`export … from` / `export *`). Export async functions defined in this file only.', + }, + }, + create(context) { + let programNode = null + let useServer = false + + const report = (node, messageId) => { + context.report({ node, messageId }) + } + + return { + Program(node) { + programNode = node + useServer = hasTopLevelUseServer(node) + }, + + ExportAllDeclaration(node) { + if (!useServer) return + if (node.exportKind === 'type') return + report(node, 'reexport') + }, + + ExportDefaultDeclaration(node) { + if (!useServer) return + + const declaration = node.declaration + if (isAsyncFunctionNode(declaration)) return + + if (declaration?.type === 'Identifier' && programNode) { + if (resolveLocalAsyncBinding(programNode, declaration.name)) return + } + + report(node, 'nonAsyncExport') + }, + + ExportNamedDeclaration(node) { + if (!useServer) return + if (isTypeOnlyNamedExport(node)) return + + if (node.source) { + const hasValueSpecifier = + !node.specifiers?.length || + node.specifiers.some((specifier) => specifier.exportKind !== 'type') + if (hasValueSpecifier) { + report(node, 'reexport') + } + return + } + + const declaration = node.declaration + + if (declaration?.type === 'FunctionDeclaration') { + if (!isAsyncFunctionNode(declaration)) { + report(node, 'nonAsyncExport') + } + return + } + + if (declaration?.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations) { + if (!isAsyncFunctionNode(declarator.init)) { + report(node, 'nonAsyncExport') + return + } + } + return + } + + if (declaration?.type === 'TSEnumDeclaration' || declaration?.type === 'ClassDeclaration') { + report(node, 'nonAsyncExport') + return + } + + if (declaration) { + // Unrecognized value declaration (e.g. const enum already covered). + if ( + declaration.type !== 'TSTypeAliasDeclaration' && + declaration.type !== 'TSInterfaceDeclaration' + ) { + report(node, 'nonAsyncExport') + } + return + } + + for (const specifier of node.specifiers ?? []) { + if (specifier.exportKind === 'type') continue + if (specifier.type !== 'ExportSpecifier') { + report(node, 'nonAsyncExport') + continue + } + + const localName = specifier.local?.name + if (!localName || !programNode || !resolveLocalAsyncBinding(programNode, localName)) { + report(specifier, 'nonAsyncExport') + } + } + }, + } + }, +} + export const localRules = { rules: { 'component-arrow-conventions': componentArrowConventions, 'framework-default-export': frameworkDefaultExport, 'no-component-default-export': noComponentDefaultExport, + 'use-server-async-exports': useServerAsyncExports, }, } diff --git a/eslint.config.mjs b/eslint.config.mjs index b551efaf..45960655 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,9 +1,9 @@ -import { dirname } from 'path' -import { fileURLToPath } from 'url' import { FlatCompat } from '@eslint/eslintrc' -import { globalIgnores } from 'eslint/config' import prettier from 'eslint-config-prettier/flat' import checkFile from 'eslint-plugin-check-file' +import { globalIgnores } from 'eslint/config' +import { dirname } from 'path' +import { fileURLToPath } from 'url' import { localRules } from './eslint-local-rules.mjs' const __filename = fileURLToPath(import.meta.url) @@ -131,6 +131,16 @@ const eslintConfig = [ ], }, }, + { + // Catch Next.js invalid-use-server-value before `next build` (Vercel). + files: ['src/**/*.{ts,tsx}'], + plugins: { + local: localRules, + }, + rules: { + 'local/use-server-async-exports': 'error', + }, + }, prettier, // Override default ignores of eslint-config-next. globalIgnores([ diff --git a/src/app/(campaign)/campanha/(app)/pracas/[slug]/editar/formActions.ts b/src/app/(campaign)/campanha/(app)/pracas/[slug]/editar/formActions.ts index 1c012b00..239fc349 100644 --- a/src/app/(campaign)/campanha/(app)/pracas/[slug]/editar/formActions.ts +++ b/src/app/(campaign)/campanha/(app)/pracas/[slug]/editar/formActions.ts @@ -14,7 +14,7 @@ import { type CampaignFormActionState, } from '@/utilities/campaignFormActionError' import { revalidatePlazaListPaths } from '@/utilities/plazaRevalidation' -import { plazaStaffEditSafeMessages } from '../../plazaStaffFormActions' +import { plazaStaffEditSafeMessages } from '../../plazaStaffEditMessages' export const updatePlazaStrategyFormAction = async ( _state: CampaignFormActionState, diff --git a/src/app/(campaign)/campanha/(app)/pracas/plazaStaffEditMessages.ts b/src/app/(campaign)/campanha/(app)/pracas/plazaStaffEditMessages.ts new file mode 100644 index 00000000..67309d7a --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/pracas/plazaStaffEditMessages.ts @@ -0,0 +1,4 @@ +/** Shared form-error copy for plaza staff edits — kept out of `'use server'` files. */ +export const plazaStaffEditSafeMessages = [ + 'Somente a coordenação e a assessoria podem editar a Praça.', +] as const diff --git a/src/app/(campaign)/campanha/(app)/pracas/plazaStaffFormActions.ts b/src/app/(campaign)/campanha/(app)/pracas/plazaStaffFormActions.ts index eb2c4b21..c6ec413c 100644 --- a/src/app/(campaign)/campanha/(app)/pracas/plazaStaffFormActions.ts +++ b/src/app/(campaign)/campanha/(app)/pracas/plazaStaffFormActions.ts @@ -20,9 +20,7 @@ import { } from '@/utilities/campaignFormActionError' import { revalidatePlazaListPaths } from '@/utilities/plazaRevalidation' -export const plazaStaffEditSafeMessages = [ - 'Somente a coordenação e a assessoria podem editar a Praça.', -] as const +import { plazaStaffEditSafeMessages } from './plazaStaffEditMessages' export const setPlazaExpectedVotesFormAction = async ( _state: CampaignFormActionState, diff --git a/tests/int/eslintConventions.int.spec.ts b/tests/int/eslintConventions.int.spec.ts index 04b60d27..935219f1 100644 --- a/tests/int/eslintConventions.int.spec.ts +++ b/tests/int/eslintConventions.int.spec.ts @@ -40,25 +40,21 @@ const expectInvalid = async (filePath: string, code: string, ruleId: string) => } describe('ESLint conventions', () => { - it( - 'enforces PascalCase component and camelCase utility/test filenames', - async () => { - await expectValid('src/components/FutureCard.tsx', 'export const FutureCard = () =>
') - await expectInvalid( - 'src/components/future-card.tsx', - 'export const FutureCard = () =>
', - 'check-file/filename-naming-convention', - ) - await expectValid('src/utilities/futureValue.ts', 'export const futureValue = 1') - await expectValid('tests/int/futureValue.int.spec.ts', 'export const futureValue = 1') - await expectInvalid( - 'tests/int/future-value.int.spec.ts', - 'export const futureValue = 1', - 'check-file/filename-naming-convention', - ) - }, - 15_000, - ) + it('enforces PascalCase component and camelCase utility/test filenames', async () => { + await expectValid('src/components/FutureCard.tsx', 'export const FutureCard = () =>
') + await expectInvalid( + 'src/components/future-card.tsx', + 'export const FutureCard = () =>
', + 'check-file/filename-naming-convention', + ) + await expectValid('src/utilities/futureValue.ts', 'export const futureValue = 1') + await expectValid('tests/int/futureValue.int.spec.ts', 'export const futureValue = 1') + await expectInvalid( + 'tests/int/future-value.int.spec.ts', + 'export const futureValue = 1', + 'check-file/filename-naming-convention', + ) + }, 15_000) it('requires uppercase component arrows to use const', async () => { await expectInvalid( @@ -73,9 +69,9 @@ describe('ESLint conventions', () => { const result = await lint('src/components/FutureCard.tsx', code) const output = await lintWithFix('src/components/FutureCard.tsx', code) - expect(result.errors.some((error) => error.ruleId === 'local/component-arrow-conventions')).toBe( - true, - ) + expect( + result.errors.some((error) => error.ruleId === 'local/component-arrow-conventions'), + ).toBe(true) expect(output).toContain('export const FutureCard = () => (
)') await expectValid( @@ -89,9 +85,9 @@ describe('ESLint conventions', () => { const result = await lint('src/components/FutureCard.tsx', code) const output = (await lintWithFix('src/components/FutureCard.tsx', code)) ?? code - expect(result.errors.some((error) => error.ruleId === 'local/component-arrow-conventions')).toBe( - true, - ) + expect( + result.errors.some((error) => error.ruleId === 'local/component-arrow-conventions'), + ).toBe(true) expect(output).toContain('keep this rationale') }) @@ -137,11 +133,7 @@ describe('ESLint conventions', () => { ] for (const code of invalidExamples) { - await expectInvalid( - 'src/app/future/page.tsx', - code, - 'local/framework-default-export', - ) + await expectInvalid('src/app/future/page.tsx', code, 'local/framework-default-export') } }) @@ -175,4 +167,33 @@ describe('ESLint conventions', () => { 'const futureConfig = { enabled: true }; export default futureConfig', ) }) + + it('rejects non-async exports from top-level use server modules', async () => { + await expectInvalid( + 'src/app/(campaign)/campanha/actions/futureAction.ts', + `'use server'\n\nexport const safeMessages = ['x'] as const\n`, + 'local/use-server-async-exports', + ) + await expectInvalid( + 'src/app/(campaign)/campanha/actions/futureAction.ts', + `'use server'\n\nexport function syncAction() { return 1 }\n`, + 'local/use-server-async-exports', + ) + await expectInvalid( + 'src/app/(campaign)/campanha/actions/futureAction.ts', + `'use server'\n\nexport { helper } from './futureHelper'\n`, + 'local/use-server-async-exports', + ) + }) + + it('allows async function and type-only exports from use server modules', async () => { + await expectValid( + 'src/app/(campaign)/campanha/actions/futureAction.ts', + `'use server'\n\nexport type Result = { ok: true }\nexport const save = async () => ({ ok: true as const })\nexport async function load() { return 1 }\n`, + ) + await expectValid( + 'src/app/(campaign)/campanha/(app)/pracas/plazaStaffEditMessages.ts', + `export const plazaStaffEditSafeMessages = ['x'] as const\n`, + ) + }) })