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 = {
|
||||
rules: {
|
||||
'component-arrow-conventions': componentArrowConventions,
|
||||
'framework-default-export': frameworkDefaultExport,
|
||||
'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 { 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([
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -40,9 +40,7 @@ const expectInvalid = async (filePath: string, code: string, ruleId: string) =>
|
|||
}
|
||||
|
||||
describe('ESLint conventions', () => {
|
||||
it(
|
||||
'enforces PascalCase component and camelCase utility/test filenames',
|
||||
async () => {
|
||||
it('enforces PascalCase component and camelCase utility/test filenames', async () => {
|
||||
await expectValid('src/components/FutureCard.tsx', 'export const FutureCard = () => <div />')
|
||||
await expectInvalid(
|
||||
'src/components/future-card.tsx',
|
||||
|
|
@ -56,9 +54,7 @@ describe('ESLint conventions', () => {
|
|||
'export const futureValue = 1',
|
||||
'check-file/filename-naming-convention',
|
||||
)
|
||||
},
|
||||
15_000,
|
||||
)
|
||||
}, 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 = () => (<div />)')
|
||||
|
||||
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`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue