mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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.
This commit is contained in:
parent
3adc8eacc3
commit
a864adc1b5
6 changed files with 237 additions and 37 deletions
|
|
@ -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 = {
|
export const localRules = {
|
||||||
rules: {
|
rules: {
|
||||||
'component-arrow-conventions': componentArrowConventions,
|
'component-arrow-conventions': componentArrowConventions,
|
||||||
'framework-default-export': frameworkDefaultExport,
|
'framework-default-export': frameworkDefaultExport,
|
||||||
'no-component-default-export': noComponentDefaultExport,
|
'no-component-default-export': noComponentDefaultExport,
|
||||||
|
'use-server-async-exports': useServerAsyncExports,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { dirname } from 'path'
|
|
||||||
import { fileURLToPath } from 'url'
|
|
||||||
import { FlatCompat } from '@eslint/eslintrc'
|
import { FlatCompat } from '@eslint/eslintrc'
|
||||||
import { globalIgnores } from 'eslint/config'
|
|
||||||
import prettier from 'eslint-config-prettier/flat'
|
import prettier from 'eslint-config-prettier/flat'
|
||||||
import checkFile from 'eslint-plugin-check-file'
|
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'
|
import { localRules } from './eslint-local-rules.mjs'
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
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,
|
prettier,
|
||||||
// Override default ignores of eslint-config-next.
|
// Override default ignores of eslint-config-next.
|
||||||
globalIgnores([
|
globalIgnores([
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import {
|
||||||
type CampaignFormActionState,
|
type CampaignFormActionState,
|
||||||
} from '@/utilities/campaignFormActionError'
|
} from '@/utilities/campaignFormActionError'
|
||||||
import { revalidatePlazaListPaths } from '@/utilities/plazaRevalidation'
|
import { revalidatePlazaListPaths } from '@/utilities/plazaRevalidation'
|
||||||
import { plazaStaffEditSafeMessages } from '../../plazaStaffFormActions'
|
import { plazaStaffEditSafeMessages } from '../../plazaStaffEditMessages'
|
||||||
|
|
||||||
export const updatePlazaStrategyFormAction = async (
|
export const updatePlazaStrategyFormAction = async (
|
||||||
_state: CampaignFormActionState,
|
_state: CampaignFormActionState,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -20,9 +20,7 @@ import {
|
||||||
} from '@/utilities/campaignFormActionError'
|
} from '@/utilities/campaignFormActionError'
|
||||||
import { revalidatePlazaListPaths } from '@/utilities/plazaRevalidation'
|
import { revalidatePlazaListPaths } from '@/utilities/plazaRevalidation'
|
||||||
|
|
||||||
export const plazaStaffEditSafeMessages = [
|
import { plazaStaffEditSafeMessages } from './plazaStaffEditMessages'
|
||||||
'Somente a coordenação e a assessoria podem editar a Praça.',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export const setPlazaExpectedVotesFormAction = async (
|
export const setPlazaExpectedVotesFormAction = async (
|
||||||
_state: CampaignFormActionState,
|
_state: CampaignFormActionState,
|
||||||
|
|
|
||||||
|
|
@ -40,25 +40,21 @@ const expectInvalid = async (filePath: string, code: string, ruleId: string) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ESLint conventions', () => {
|
describe('ESLint conventions', () => {
|
||||||
it(
|
it('enforces PascalCase component and camelCase utility/test filenames', async () => {
|
||||||
'enforces PascalCase component and camelCase utility/test filenames',
|
await expectValid('src/components/FutureCard.tsx', 'export const FutureCard = () => <div />')
|
||||||
async () => {
|
await expectInvalid(
|
||||||
await expectValid('src/components/FutureCard.tsx', 'export const FutureCard = () => <div />')
|
'src/components/future-card.tsx',
|
||||||
await expectInvalid(
|
'export const FutureCard = () => <div />',
|
||||||
'src/components/future-card.tsx',
|
'check-file/filename-naming-convention',
|
||||||
'export const FutureCard = () => <div />',
|
)
|
||||||
'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 expectValid('src/utilities/futureValue.ts', 'export const futureValue = 1')
|
await expectInvalid(
|
||||||
await expectValid('tests/int/futureValue.int.spec.ts', 'export const futureValue = 1')
|
'tests/int/future-value.int.spec.ts',
|
||||||
await expectInvalid(
|
'export const futureValue = 1',
|
||||||
'tests/int/future-value.int.spec.ts',
|
'check-file/filename-naming-convention',
|
||||||
'export const futureValue = 1',
|
)
|
||||||
'check-file/filename-naming-convention',
|
}, 15_000)
|
||||||
)
|
|
||||||
},
|
|
||||||
15_000,
|
|
||||||
)
|
|
||||||
|
|
||||||
it('requires uppercase component arrows to use const', async () => {
|
it('requires uppercase component arrows to use const', async () => {
|
||||||
await expectInvalid(
|
await expectInvalid(
|
||||||
|
|
@ -73,9 +69,9 @@ describe('ESLint conventions', () => {
|
||||||
const result = await lint('src/components/FutureCard.tsx', code)
|
const result = await lint('src/components/FutureCard.tsx', code)
|
||||||
const output = await lintWithFix('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(
|
expect(
|
||||||
true,
|
result.errors.some((error) => error.ruleId === 'local/component-arrow-conventions'),
|
||||||
)
|
).toBe(true)
|
||||||
expect(output).toContain('export const FutureCard = () => (<div />)')
|
expect(output).toContain('export const FutureCard = () => (<div />)')
|
||||||
|
|
||||||
await expectValid(
|
await expectValid(
|
||||||
|
|
@ -89,9 +85,9 @@ describe('ESLint conventions', () => {
|
||||||
const result = await lint('src/components/FutureCard.tsx', code)
|
const result = await lint('src/components/FutureCard.tsx', code)
|
||||||
const output = (await lintWithFix('src/components/FutureCard.tsx', code)) ?? code
|
const output = (await lintWithFix('src/components/FutureCard.tsx', code)) ?? code
|
||||||
|
|
||||||
expect(result.errors.some((error) => error.ruleId === 'local/component-arrow-conventions')).toBe(
|
expect(
|
||||||
true,
|
result.errors.some((error) => error.ruleId === 'local/component-arrow-conventions'),
|
||||||
)
|
).toBe(true)
|
||||||
expect(output).toContain('keep this rationale')
|
expect(output).toContain('keep this rationale')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -137,11 +133,7 @@ describe('ESLint conventions', () => {
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const code of invalidExamples) {
|
for (const code of invalidExamples) {
|
||||||
await expectInvalid(
|
await expectInvalid('src/app/future/page.tsx', code, 'local/framework-default-export')
|
||||||
'src/app/future/page.tsx',
|
|
||||||
code,
|
|
||||||
'local/framework-default-export',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -175,4 +167,33 @@ describe('ESLint conventions', () => {
|
||||||
'const futureConfig = { enabled: true }; export default futureConfig',
|
'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`,
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue