website refactor

This commit is contained in:
2026-01-14 02:02:24 +01:00
parent 8d7c709e0c
commit 4522d41aef
291 changed files with 12763 additions and 9309 deletions

View File

@@ -34,12 +34,17 @@ const viewModelBuilderContract = require('./view-model-builder-contract');
const singleExportPerFile = require('./single-export-per-file');
const filenameMatchesExport = require('./filename-matches-export');
const pageQueryMustUseBuilders = require('./page-query-must-use-builders');
const pageQueryMustMapErrors = require('./page-query-must-map-errors');
const mutationMustUseBuilders = require('./mutation-must-use-builders');
const mutationMustMapErrors = require('./mutation-must-map-errors');
const serviceFunctionFormat = require('./service-function-format');
const libNoNextImports = require('./lib-no-next-imports');
const servicesNoInstantiation = require('./services-no-instantiation');
const noPageDtosDirectory = require('./no-page-dtos-directory');
const cleanErrorHandling = require('./clean-error-handling');
const servicesImplementContract = require('./services-implement-contract');
const serverActionsReturnResult = require('./server-actions-return-result');
const serverActionsInterface = require('./server-actions-interface');
module.exports = {
rules: {
@@ -80,9 +85,9 @@ module.exports = {
'page-query-contract': pageQueryRules['pagequery-must-implement-contract'],
'page-query-execute': pageQueryRules['pagequery-must-have-execute'],
'page-query-return-type': pageQueryRules['pagequery-execute-return-type'],
'page-query-must-map-errors': pageQueryMustMapErrors,
// Services Rules
'services-must-be-marked': servicesRules['services-must-be-marked'],
'services-no-external-api': servicesRules['no-external-api-in-services'],
'services-must-be-pure': servicesRules['services-must-be-pure'],
'services-must-return-result': cleanErrorHandling,
@@ -109,9 +114,13 @@ module.exports = {
// Mutation Rules
'mutation-contract': mutationContract,
'mutation-must-use-builders': mutationMustUseBuilders,
'mutation-must-map-errors': mutationMustMapErrors,
// Server Actions Rules
'server-actions-must-use-mutations': serverActionsMustUseMutations,
'server-actions-return-result': serverActionsReturnResult,
'server-actions-interface': serverActionsInterface,
// View Data Rules
'view-data-location': viewDataLocation,
@@ -148,6 +157,15 @@ module.exports = {
// Route Configuration Rules
'no-hardcoded-routes': require('./no-hardcoded-routes'),
'no-hardcoded-search-params': require('./no-hardcoded-search-params'),
// Logging Rules
'no-console': require('./no-console'),
// Cookies
'no-next-cookies-in-pages': require('./no-next-cookies-in-pages'),
// Config
'no-direct-process-env': require('./no-direct-process-env'),
// Architecture Rules
'no-index-files': require('./no-index-files'),
@@ -183,7 +201,7 @@ module.exports = {
'gridpilot-rules/template-no-external-state': 'error',
'gridpilot-rules/template-no-global-objects': 'error',
'gridpilot-rules/template-no-mutation-props': 'error',
'gridpilot-rules/template-no-unsafe-html': 'error',
'gridpilot-rules/template-no-unsafe-html': 'warn',
// Display Objects
'gridpilot-rules/display-no-domain-models': 'error',
@@ -197,7 +215,6 @@ module.exports = {
'gridpilot-rules/page-query-return-type': 'error',
// Services
'gridpilot-rules/services-must-be-marked': 'error',
'gridpilot-rules/services-no-external-api': 'error',
'gridpilot-rules/services-must-be-pure': 'error',
@@ -220,9 +237,13 @@ module.exports = {
// Mutations
'gridpilot-rules/mutation-contract': 'error',
'gridpilot-rules/mutation-must-use-builders': 'error',
'gridpilot-rules/mutation-must-map-errors': 'error',
// Server Actions
'gridpilot-rules/server-actions-must-use-mutations': 'error',
'gridpilot-rules/server-actions-return-result': 'error',
'gridpilot-rules/server-actions-interface': 'error',
// View Data
'gridpilot-rules/view-data-location': 'error',
@@ -243,7 +264,7 @@ module.exports = {
'gridpilot-rules/lib-no-next-imports': 'error',
// Component Architecture Rules
'gridpilot-rules/no-raw-html-in-app': 'error',
'gridpilot-rules/no-raw-html-in-app': 'warn',
'gridpilot-rules/ui-element-purity': 'error',
'gridpilot-rules/no-nextjs-imports-in-ui': 'error',
'gridpilot-rules/component-classification': 'warn',

View File

@@ -1,19 +1,24 @@
/**
* ESLint Rule: Mutation Contract
*
* Ensures mutations return Result type
* Enforces the basic Mutation contract:
* - Mutation classes should `implement Mutation<...>` (type-level contract)
* - `execute()` must return `Promise<Result<...>>`
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Ensure mutations return Result type',
description: 'Ensure mutations implement the Mutation contract and return Result',
category: 'Mutation Contract',
recommended: true,
},
messages: {
wrongReturnType: 'Mutations must return Promise<Result<void, string>> - see apps/website/lib/contracts/Result.ts',
mustImplementMutationInterface:
'Mutation classes must implement Mutation<TInput, TOutput, TError> - see apps/website/lib/contracts/mutations/Mutation.ts',
wrongReturnType:
'Mutation execute() must return Promise<Result<TOutput, TError>> - see apps/website/lib/contracts/Result.ts',
},
schema: [],
},
@@ -21,50 +26,112 @@ module.exports = {
create(context) {
const filename = context.getFilename();
// Only apply to mutation files
if (!filename.includes('/lib/mutations/') || !filename.endsWith('.ts')) {
return {};
}
const mutationInterfaceNames = new Set(['Mutation']);
function isIdentifier(node, nameSet) {
return node && node.type === 'Identifier' && nameSet.has(node.name);
}
function isPromiseType(typeAnnotation) {
if (!typeAnnotation || typeAnnotation.type !== 'TSTypeReference') return false;
const typeName = typeAnnotation.typeName;
return typeName && typeName.type === 'Identifier' && typeName.name === 'Promise';
}
function isResultType(typeNode) {
if (!typeNode || typeNode.type !== 'TSTypeReference') return false;
const typeName = typeNode.typeName;
if (!typeName) return false;
// Common case: Result<...>
if (typeName.type === 'Identifier') {
return typeName.name === 'Result';
}
// Fallback: handle qualified names (rare)
if (typeName.type === 'TSQualifiedName') {
return typeName.right && typeName.right.type === 'Identifier' && typeName.right.name === 'Result';
}
return false;
}
return {
ImportDeclaration(node) {
const importPath = node.source && node.source.value;
if (typeof importPath !== 'string') return;
// Accept both alias and relative imports.
const isMutationContractImport =
importPath.includes('/lib/contracts/mutations/Mutation') ||
importPath.endsWith('lib/contracts/mutations/Mutation') ||
importPath.endsWith('contracts/mutations/Mutation') ||
importPath.endsWith('contracts/mutations/Mutation.ts');
if (!isMutationContractImport) return;
for (const spec of node.specifiers || []) {
// import { Mutation as X } from '...'
if (spec.type === 'ImportSpecifier' && spec.imported && spec.imported.type === 'Identifier') {
mutationInterfaceNames.add(spec.local.name);
}
}
},
ClassDeclaration(node) {
if (!node.id || node.id.type !== 'Identifier') return;
if (!node.id.name.endsWith('Mutation')) return;
const implementsNodes = node.implements || [];
const implementsMutation = implementsNodes.some((impl) => {
// `implements Mutation<...>`
return impl && isIdentifier(impl.expression, mutationInterfaceNames);
});
if (!implementsMutation) {
context.report({
node: node.id,
messageId: 'mustImplementMutationInterface',
});
}
},
MethodDefinition(node) {
if (node.key.type === 'Identifier' &&
node.key.name === 'execute' &&
node.value.type === 'FunctionExpression') {
const returnType = node.value.returnType;
// Check if it returns Promise<Result<...>>
if (!returnType ||
!returnType.typeAnnotation ||
!returnType.typeAnnotation.typeName ||
returnType.typeAnnotation.typeName.name !== 'Promise') {
context.report({
node,
messageId: 'wrongReturnType',
});
return;
}
if (node.key.type !== 'Identifier' || node.key.name !== 'execute') return;
if (!node.value) return;
// Check for Result type
const typeArgs = returnType.typeAnnotation.typeParameters;
if (!typeArgs || !typeArgs.params || typeArgs.params.length === 0) {
context.report({
node,
messageId: 'wrongReturnType',
});
return;
}
const returnType = node.value.returnType;
const typeAnnotation = returnType && returnType.typeAnnotation;
const resultType = typeArgs.params[0];
if (resultType.type !== 'TSTypeReference' ||
!resultType.typeName ||
(resultType.typeName.type === 'Identifier' && resultType.typeName.name !== 'Result')) {
context.report({
node,
messageId: 'wrongReturnType',
});
}
// Must be Promise<...>
if (!isPromiseType(typeAnnotation)) {
context.report({
node,
messageId: 'wrongReturnType',
});
return;
}
const promiseTypeArgs = typeAnnotation.typeParameters;
if (!promiseTypeArgs || !promiseTypeArgs.params || promiseTypeArgs.params.length === 0) {
context.report({
node,
messageId: 'wrongReturnType',
});
return;
}
// Must be Promise<Result<...>> (we don't constrain the generics here)
const inner = promiseTypeArgs.params[0];
if (!isResultType(inner)) {
context.report({
node,
messageId: 'wrongReturnType',
});
}
},
};

View File

@@ -0,0 +1,227 @@
/**
* ESLint Rule: Mutation Must Map Errors
*
* Enforces the architecture documented in `lib/contracts/mutations/Mutation.ts`:
* - Mutations call Services.
* - Service returns Result<..., DomainError>.
* - Mutation maps DomainError -> MutationError via mapToMutationError.
* - Mutation returns Result<..., MutationError>.
*
* Strict enforcement (scoped to service results):
* - Disallow returning a Service Result directly.
* - `return result;` where `result` came from `await service.*(...)`
* - `return service.someMethod(...)`
* - `return await service.someMethod(...)`
* - If a mutation does `if (result.isErr()) return Result.err(...)`,
* it must be `Result.err(mapToMutationError(result.getError()))`.
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Require mutations to map service errors via mapToMutationError',
category: 'Architecture',
recommended: true,
},
schema: [],
messages: {
mustMapMutationError:
'Mutations must map service errors via mapToMutationError(result.getError()) before returning Result.err(...) - see apps/website/lib/contracts/mutations/Mutation.ts',
mustNotReturnServiceResult:
'Mutations must not return a Service Result directly; map errors and return a Mutation-layer Result instead - see apps/website/lib/contracts/mutations/Mutation.ts',
},
},
create(context) {
const filename = context.getFilename();
if (!filename.includes('/lib/mutations/') || !filename.endsWith('.ts')) {
return {};
}
const resultVarsFromServiceCall = new Set();
const serviceVarNames = new Set();
const mapToMutationErrorNames = new Set(['mapToMutationError']);
function isIdentifier(node, name) {
return node && node.type === 'Identifier' && node.name === name;
}
function isResultErrCall(node) {
return (
node &&
node.type === 'CallExpression' &&
node.callee &&
node.callee.type === 'MemberExpression' &&
!node.callee.computed &&
node.callee.object &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'Result' &&
node.callee.property &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'err'
);
}
function isMapToMutationErrorCall(node) {
return (
node &&
node.type === 'CallExpression' &&
node.callee &&
node.callee.type === 'Identifier' &&
mapToMutationErrorNames.has(node.callee.name)
);
}
function isGetErrorCall(node, resultVarName) {
return (
node &&
node.type === 'CallExpression' &&
node.callee &&
node.callee.type === 'MemberExpression' &&
!node.callee.computed &&
node.callee.object &&
isIdentifier(node.callee.object, resultVarName) &&
node.callee.property &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'getError'
);
}
function isServiceObjectName(name) {
return typeof name === 'string' && (name.endsWith('Service') || serviceVarNames.has(name));
}
function isServiceMemberCallExpression(callExpression) {
if (!callExpression || callExpression.type !== 'CallExpression') return false;
const callee = callExpression.callee;
if (!callee || callee.type !== 'MemberExpression' || callee.computed) return false;
if (!callee.object || callee.object.type !== 'Identifier') return false;
return isServiceObjectName(callee.object.name);
}
function isAwaitedServiceCall(init) {
if (!init || init.type !== 'AwaitExpression') return false;
return isServiceMemberCallExpression(init.argument);
}
function collectReturnStatements(statementNode, callback) {
if (!statementNode) return;
if (statementNode.type !== 'BlockStatement') {
if (statementNode.type === 'ReturnStatement') callback(statementNode);
return;
}
for (const s of statementNode.body || []) {
if (!s) continue;
if (s.type === 'ReturnStatement') callback(s);
}
}
return {
ImportDeclaration(node) {
const importPath = node.source && node.source.value;
if (typeof importPath !== 'string') return;
const isMutationErrorImport =
importPath.includes('/lib/contracts/mutations/MutationError') ||
importPath.endsWith('lib/contracts/mutations/MutationError') ||
importPath.endsWith('contracts/mutations/MutationError') ||
importPath.endsWith('contracts/mutations/MutationError.ts');
if (!isMutationErrorImport) return;
for (const spec of node.specifiers || []) {
// import { mapToMutationError as X } from '...'
if (spec.type === 'ImportSpecifier' && spec.imported && spec.imported.type === 'Identifier') {
if (spec.imported.name === 'mapToMutationError') {
mapToMutationErrorNames.add(spec.local.name);
}
}
}
},
VariableDeclarator(node) {
if (!node.id || node.id.type !== 'Identifier') return;
// const service = new SomeService();
if (node.init && node.init.type === 'NewExpression' && node.init.callee && node.init.callee.type === 'Identifier') {
if (node.init.callee.name.endsWith('Service')) {
serviceVarNames.add(node.id.name);
}
}
// const result = await service.method(...)
if (!isAwaitedServiceCall(node.init)) return;
resultVarsFromServiceCall.add(node.id.name);
},
ReturnStatement(node) {
if (!node.argument) return;
// return result;
if (node.argument.type === 'Identifier') {
if (resultVarsFromServiceCall.has(node.argument.name)) {
context.report({ node, messageId: 'mustNotReturnServiceResult' });
}
return;
}
// return service.method(...)
if (node.argument.type === 'CallExpression') {
if (isServiceMemberCallExpression(node.argument)) {
context.report({ node, messageId: 'mustNotReturnServiceResult' });
}
return;
}
// return await service.method(...)
if (node.argument.type === 'AwaitExpression') {
if (isServiceMemberCallExpression(node.argument.argument)) {
context.report({ node, messageId: 'mustNotReturnServiceResult' });
}
}
},
IfStatement(node) {
// if (result.isErr()) { return Result.err(...) }
const test = node.test;
if (!test || test.type !== 'CallExpression') return;
const testCallee = test.callee;
if (
!testCallee ||
testCallee.type !== 'MemberExpression' ||
testCallee.computed ||
!testCallee.object ||
testCallee.object.type !== 'Identifier' ||
!testCallee.property ||
testCallee.property.type !== 'Identifier' ||
testCallee.property.name !== 'isErr'
) {
return;
}
const resultVarName = testCallee.object.name;
if (!resultVarsFromServiceCall.has(resultVarName)) return;
collectReturnStatements(node.consequent, (returnNode) => {
const arg = returnNode.argument;
if (!isResultErrCall(arg)) return;
const errArg = arg.arguments && arg.arguments[0];
if (!isMapToMutationErrorCall(errArg)) {
context.report({ node: returnNode, messageId: 'mustMapMutationError' });
return;
}
const mappedFrom = errArg.arguments && errArg.arguments[0];
if (!isGetErrorCall(mappedFrom, resultVarName)) {
context.report({ node: returnNode, messageId: 'mustMapMutationError' });
}
});
},
};
},
};

View File

@@ -0,0 +1,211 @@
/**
* ESLint Rule: Mutation Must Use Builders
*
* Ensures mutations use builders to transform DTOs into ViewData
* or return appropriate simple types (void, string, etc.)
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Ensure mutations use builders or return appropriate types, not DTOs',
category: 'Mutation',
recommended: true,
},
messages: {
mustUseBuilder: 'Mutations must use ViewDataBuilder to transform DTOs or return simple types - see apps/website/lib/contracts/builders/ViewDataBuilder.ts',
noDirectDtoReturn: 'Mutations must not return DTOs directly, use builders to create ViewData or return simple types like void, string, or primitive values',
},
schema: [],
},
create(context) {
const filename = context.getFilename();
const isMutation = filename.includes('/lib/mutations/') && filename.endsWith('.ts');
if (!isMutation) {
return {};
}
let hasBuilderImport = false;
const returnStatements = [];
const methodDefinitions = [];
return {
// Check for builder imports
ImportDeclaration(node) {
const importPath = node.source.value;
if (importPath.includes('/builders/view-data/')) {
hasBuilderImport = true;
}
},
// Track method definitions
MethodDefinition(node) {
if (node.key.type === 'Identifier' && node.key.name === 'execute') {
methodDefinitions.push(node);
}
},
// Track return statements in execute method
ReturnStatement(node) {
// Only track returns inside execute method
let parent = node;
while (parent) {
if (parent.type === 'MethodDefinition' &&
parent.key.type === 'Identifier' &&
parent.key.name === 'execute') {
if (node.argument) {
returnStatements.push(node);
}
break;
}
parent = parent.parent;
}
},
'Program:exit'() {
if (methodDefinitions.length === 0) {
return; // No execute method found
}
// Check each return statement in execute method
returnStatements.forEach(returnNode => {
const returnExpr = returnNode.argument;
if (!returnExpr) return;
// Check if it's a Result type
if (returnExpr.type === 'CallExpression') {
const callee = returnExpr.callee;
// Check for Result.ok() or Result.err()
if (callee.type === 'MemberExpression' &&
callee.object.type === 'Identifier' &&
callee.object.name === 'Result' &&
callee.property.type === 'Identifier' &&
(callee.property.name === 'ok' || callee.property.name === 'err')) {
const resultArg = returnExpr.arguments[0];
if (callee.property.name === 'ok') {
// Check what's being returned in Result.ok()
// If it's a builder call, that's good
if (resultArg && resultArg.type === 'CallExpression') {
const builderCallee = resultArg.callee;
if (builderCallee.type === 'MemberExpression' &&
builderCallee.property.type === 'Identifier' &&
builderCallee.property.name === 'build') {
return; // Good: using builder
}
}
// If it's a method call that might be unwrap() or similar
if (resultArg && resultArg.type === 'CallExpression') {
const methodCallee = resultArg.callee;
if (methodCallee.type === 'MemberExpression' &&
methodCallee.property.type === 'Identifier') {
const methodName = methodCallee.property.name;
// Common DTO extraction methods
if (['unwrap', 'getValue', 'getData', 'getResult'].includes(methodName)) {
context.report({
node: returnNode,
messageId: 'noDirectDtoReturn',
});
return;
}
}
}
// If it's a simple type (string, number, boolean, void), that's OK
if (resultArg && (
resultArg.type === 'Literal' ||
resultArg.type === 'Identifier' &&
['void', 'undefined', 'null'].includes(resultArg.name) ||
resultArg.type === 'UnaryExpression' // e.g., undefined
)) {
return; // Good: simple type
}
// If it's an identifier that might be a DTO
if (resultArg && resultArg.type === 'Identifier') {
const varName = resultArg.name;
// Check if it's likely a DTO (ends with DTO, or common patterns)
if (varName.match(/(DTO|Result|Response|Data)$/i) &&
!varName.includes('ViewData') &&
!varName.includes('ViewModel')) {
context.report({
node: returnNode,
messageId: 'noDirectDtoReturn',
});
}
}
// If it's an object literal with DTO-like properties
if (resultArg && resultArg.type === 'ObjectExpression') {
const hasDtoLikeProps = resultArg.properties.some(prop => {
if (prop.type === 'Property' && prop.key.type === 'Identifier') {
const keyName = prop.key.name;
return keyName.match(/(id|Id|URL|Url|DTO)$/i);
}
return false;
});
if (hasDtoLikeProps) {
context.report({
node: returnNode,
messageId: 'noDirectDtoReturn',
});
}
}
}
}
}
});
// If no builder import and we have DTO returns, report
if (!hasBuilderImport && returnStatements.length > 0) {
const hasDtoReturn = returnStatements.some(returnNode => {
const returnExpr = returnNode.argument;
if (returnExpr && returnExpr.type === 'CallExpression') {
const callee = returnExpr.callee;
if (callee.type === 'MemberExpression' &&
callee.object.type === 'Identifier' &&
callee.object.name === 'Result' &&
callee.property.name === 'ok') {
const arg = returnExpr.arguments[0];
// Check for method calls like result.unwrap()
if (arg && arg.type === 'CallExpression') {
const methodCallee = arg.callee;
if (methodCallee.type === 'MemberExpression' &&
methodCallee.property.type === 'Identifier') {
const methodName = methodCallee.property.name;
if (['unwrap', 'getValue', 'getData', 'getResult'].includes(methodName)) {
return true;
}
}
}
// Check for identifier
if (arg && arg.type === 'Identifier') {
return arg.name.match(/(DTO|Result|Response|Data)$/i);
}
}
}
return false;
});
if (hasDtoReturn) {
context.report({
node: methodDefinitions[0],
messageId: 'mustUseBuilder',
});
}
}
},
};
},
};

View File

@@ -0,0 +1,48 @@
/**
* @file no-console.js
* Forbid console usage in website code.
*
* Use ConsoleLogger instead:
* import { logger } from '@/lib/infrastructure/logging/logger'
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Forbid console.* usage (use ConsoleLogger wrapper instead)',
category: 'Logging',
recommended: true,
},
schema: [],
messages: {
noConsole:
'Do not use console. Use the project logger instead (ConsoleLogger). Import: `logger` from `@/lib/infrastructure/logging/logger`.',
},
},
create(context) {
const filename = context.getFilename();
// Allow console within the logging infrastructure itself.
// (ConsoleLogger implements the console adapter.)
if (
filename.includes('/lib/infrastructure/logging/') ||
filename.includes('/lib/infrastructure/EnhancedErrorReporter') ||
filename.includes('/lib/infrastructure/GlobalErrorHandler') ||
filename.includes('/lib/infrastructure/ErrorReplay') ||
filename.includes('/eslint-rules/')
) {
return {};
}
return {
MemberExpression(node) {
// console.log / console.error / console.warn / console.info / console.debug / console.group* ...
if (node.object && node.object.type === 'Identifier' && node.object.name === 'console') {
context.report({ node, messageId: 'noConsole' });
}
},
};
},
};

View File

@@ -0,0 +1,56 @@
/**
* @file no-direct-process-env.js
* Enforce centralized env/config access.
*
* Prefer:
* - getWebsiteServerEnv()/getWebsitePublicEnv() from '@/lib/config/env'
* - getWebsiteApiBaseUrl() from '@/lib/config/apiBaseUrl'
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Forbid direct process.env reads outside of config modules',
category: 'Configuration',
recommended: true,
},
schema: [],
messages: {
noProcessEnv:
'Do not read process.env directly here. Use `getWebsiteServerEnv()` / `getWebsitePublicEnv()` (apps/website/lib/config/env.ts) or a dedicated config helper (e.g. getWebsiteApiBaseUrl()).',
},
},
create(context) {
const filename = context.getFilename();
// Allow env reads in config layer and low-level infrastructure that must branch by env.
if (
filename.includes('/lib/config/') ||
filename.includes('/lib/infrastructure/logging/') ||
filename.includes('/eslint-rules/')
) {
return {};
}
return {
MemberExpression(node) {
// process.env.X
if (
node.object &&
node.object.type === 'MemberExpression' &&
node.object.object &&
node.object.object.type === 'Identifier' &&
node.object.object.name === 'process' &&
node.object.property &&
node.object.property.type === 'Identifier' &&
node.object.property.name === 'env'
) {
context.report({ node, messageId: 'noProcessEnv' });
}
},
};
},
};

View File

@@ -166,7 +166,7 @@ module.exports = {
// Check router.push() and router.replace()
CallExpression(node) {
if (node.callee.type === 'MemberExpression' &&
node.callee.property.name === 'push' || node.callee.property.name === 'replace') {
(node.callee.property.name === 'push' || node.callee.property.name === 'replace')) {
// Check if it's router.push/replace
const calleeObj = node.callee.object;

View File

@@ -17,10 +17,61 @@ module.exports = {
manualSearchParams: 'Manual URLSearchParams construction. Use SearchParamBuilder instead: import { SearchParamBuilder } from "@/lib/routing/search-params"',
manualGetParam: 'Manual search param access with get(). Use SearchParamParser instead: import { SearchParamParser } from "@/lib/routing/search-params"',
manualSetParam: 'Manual search param setting with set(). Use SearchParamBuilder instead',
manualQueryString:
'Manual query-string construction detected (e.g. "?returnTo=..."). Use SearchParamBuilder instead: import { SearchParamBuilder } from "@/lib/routing/search-params/SearchParamBuilder"',
},
},
create(context) {
const SEARCH_PARAM_KEYS = new Set([
// Auth
'returnTo',
'token',
'email',
'error',
'message',
// Sponsor
'type',
'campaignId',
// Pagination
'page',
'limit',
'offset',
// Sorting
'sortBy',
'order',
// Filters
'status',
'role',
'tier',
]);
/**
* Detect patterns like:
* - "?returnTo="
* - "&returnTo="
* - "?page="
* - "returnTo=" (within a URL string)
*/
function containsManualQueryParamFragment(raw) {
if (typeof raw !== 'string' || raw.length === 0) return false;
// Fast pre-check
if (!raw.includes('?') && !raw.includes('&') && !raw.includes('=')) return false;
for (const key of SEARCH_PARAM_KEYS) {
if (
raw.includes(`?${key}=`) ||
raw.includes(`&${key}=`) ||
// catches "...returnTo=..." in some string-building scenarios
raw.includes(`${key}=`)
) {
return true;
}
}
return false;
}
return {
// Detect: new URLSearchParams()
NewExpression(node) {
@@ -49,6 +100,42 @@ module.exports = {
}
},
// Detect manual query strings, e.g.
// `${routes.auth.login}?returnTo=${routes.protected.onboarding}`
// routes.auth.login + '?returnTo=' + routes.protected.onboarding
TemplateLiteral(node) {
// If any static chunk contains a query-param fragment, treat it as manual.
for (const quasi of node.quasis) {
const raw = quasi.value && (quasi.value.raw ?? quasi.value.cooked);
if (containsManualQueryParamFragment(raw)) {
context.report({
node,
messageId: 'manualQueryString',
});
return;
}
}
},
BinaryExpression(node) {
// String concatenation patterns, e.g. a + '?returnTo=' + b
if (node.operator !== '+') return;
// If either side is a literal string containing query params, report.
const left = node.left;
const right = node.right;
if (left && left.type === 'Literal' && typeof left.value === 'string' && containsManualQueryParamFragment(left.value)) {
context.report({ node, messageId: 'manualQueryString' });
return;
}
if (right && right.type === 'Literal' && typeof right.value === 'string' && containsManualQueryParamFragment(right.value)) {
context.report({ node, messageId: 'manualQueryString' });
return;
}
},
// Detect: params.get() or params.set()
CallExpression(node) {
if (node.callee.type === 'MemberExpression') {

View File

@@ -0,0 +1,57 @@
/**
* @file no-next-cookies-in-pages.js
*
* Forbid direct cookie access in Next.js App Router pages.
*
* Rationale:
* - Pages should stay focused on orchestration/rendering.
* - Cookie parsing/auth/session concerns belong in middleware/layout boundaries or gateways.
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Forbid using next/headers cookies() in app/**/page.* files',
category: 'Architecture',
recommended: true,
},
schema: [],
messages: {
noCookiesInPages:
'Do not use cookies() in App Router pages. Move cookie/session handling to a layout boundary or middleware (or a dedicated gateway).',
},
},
create(context) {
const filename = context.getFilename();
const isPageFile = /\/app\/.*\/page\.(ts|tsx)$/.test(filename);
if (!isPageFile) return {};
return {
ImportDeclaration(node) {
if (node.source && node.source.value === 'next/headers') {
for (const spec of node.specifiers || []) {
if (spec.type === 'ImportSpecifier' && spec.imported && spec.imported.name === 'cookies') {
context.report({ node: spec, messageId: 'noCookiesInPages' });
}
}
}
},
CallExpression(node) {
if (node.callee && node.callee.type === 'Identifier' && node.callee.name === 'cookies') {
context.report({ node, messageId: 'noCookiesInPages' });
}
},
ImportExpression(node) {
// Also catch: await import('next/headers') in a page.
if (node.source && node.source.type === 'Literal' && node.source.value === 'next/headers') {
context.report({ node, messageId: 'noCookiesInPages' });
}
},
};
},
};

View File

@@ -1,43 +1,66 @@
/**
* ESLint rule to forbid raw HTML in app/ directory
* ESLint rule to forbid raw HTML in app/, components/, and templates/ directories
*
* All HTML must be encapsulated in React components from components/ or ui/
* All HTML must be encapsulated in React components from ui/ directory
*
* Rationale:
* - app/ should only contain page/layout components
* - components/ should use ui/ elements for all rendering
* - templates/ should use ui/ elements for all rendering
* - Raw HTML with styling violates separation of concerns
* - UI logic belongs in components/ui layers
* - UI elements ensure consistency and reusability
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Forbid raw HTML with styling in app/ directory',
description: 'Forbid raw HTML with styling in app/, components/, and templates/ directories',
category: 'Architecture',
recommended: true,
},
fixable: null,
schema: [],
messages: {
noRawHtml: 'Raw HTML with styling is forbidden in app/. Use a component from components/ or ui/.',
noRawHtml: 'Raw HTML with styling is forbidden. Use a UI element from ui/ directory.',
noRawHtmlInApp: 'Raw HTML in app/ is forbidden. Use components/ or ui/ elements.',
noRawHtmlInComponents: 'Raw HTML in components/ should use ui/ elements instead.',
noRawHtmlInTemplates: 'Raw HTML in templates/ should use ui/ elements instead.',
},
},
create(context) {
const filename = context.getFilename();
const isInApp = filename.includes('/app/');
if (!isInApp) return {};
// Determine which layer we're in
const isInApp = filename.includes('/app/');
const isInComponents = filename.includes('/components/');
const isInTemplates = filename.includes('/templates/');
// Only apply to these UI layers
if (!isInApp && !isInComponents && !isInTemplates) {
return {};
}
// HTML tags that should be wrapped in components
// HTML tags that should be wrapped in UI elements
const htmlTags = [
'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'button', 'input', 'form', 'label', 'select', 'textarea',
'ul', 'ol', 'li', 'table', 'tr', 'td', 'th', 'thead', 'tbody',
'section', 'article', 'header', 'footer', 'nav', 'aside',
'main', 'aside', 'figure', 'figcaption', 'blockquote', 'code',
'pre', 'a', 'img', 'svg', 'path', 'g', 'rect', 'circle'
'pre', 'a', 'img', 'svg', 'path', 'g', 'rect', 'circle',
'hr', 'br', 'strong', 'em', 'b', 'i', 'u', 'small', 'mark'
];
// UI elements that are allowed (from ui/ directory)
const allowedUiElements = [
'Button', 'Input', 'Form', 'Label', 'Select', 'Textarea',
'Card', 'Container', 'Grid', 'Stack', 'Box', 'Text', 'Heading',
'List', 'Table', 'Section', 'Article', 'Header', 'Footer', 'Nav', 'Aside',
'Link', 'Image', 'Icon', 'Avatar', 'Badge', 'Chip', 'Pill',
'Modal', 'Dialog', 'Toast', 'Notification', 'Alert',
'StepIndicator', 'Loading', 'Spinner', 'Progress'
];
return {
@@ -48,6 +71,11 @@ module.exports = {
const tagName = openingElement.name.name;
// Skip allowed UI elements
if (allowedUiElements.includes(tagName)) {
return;
}
// Check if it's a raw HTML element (lowercase)
if (htmlTags.includes(tagName) && tagName[0] === tagName[0].toLowerCase()) {
@@ -59,21 +87,85 @@ module.exports = {
attr => attr.type === 'JSXAttribute' && attr.name.name === 'style'
);
// Check for inline event handlers (also a concern)
// Check for inline event handlers
const hasInlineHandlers = openingElement.attributes.some(
attr => attr.type === 'JSXAttribute' &&
attr.name.name &&
attr.name.name.startsWith('on')
);
if (hasClassName || hasStyle || hasInlineHandlers) {
// Check for other common attributes that suggest styling/behavior
const hasCommonAttrs = openingElement.attributes.some(
attr => attr.type === 'JSXAttribute' &&
['id', 'role', 'aria-label', 'aria-hidden'].includes(attr.name.name)
);
if (hasClassName || hasStyle || hasInlineHandlers || hasCommonAttrs) {
let messageId = 'noRawHtml';
if (isInApp) {
messageId = 'noRawHtmlInApp';
} else if (isInComponents) {
messageId = 'noRawHtmlInComponents';
} else if (isInTemplates) {
messageId = 'noRawHtmlInTemplates';
}
context.report({
node,
messageId: 'noRawHtml',
messageId,
});
}
}
},
// Also check for dangerouslySetInnerHTML
JSXAttribute(node) {
if (node.name.name === 'dangerouslySetInnerHTML') {
let messageId = 'noRawHtml';
if (isInApp) {
messageId = 'noRawHtmlInApp';
} else if (isInComponents) {
messageId = 'noRawHtmlInComponents';
} else if (isInTemplates) {
messageId = 'noRawHtmlInTemplates';
}
context.report({
node,
messageId,
});
}
},
// Check for HTML strings in JSX expressions
JSXExpressionContainer(node) {
if (node.expression.type === 'Literal' && typeof node.expression.value === 'string') {
const value = node.expression.value.trim();
// Check if it contains HTML-like content
if (value.includes('<') && value.includes('>') &&
(value.includes('class=') || value.includes('style=') ||
value.match(/<\w+[^>]*>/))) {
let messageId = 'noRawHtml';
if (isInApp) {
messageId = 'noRawHtmlInApp';
} else if (isInComponents) {
messageId = 'noRawHtmlInComponents';
} else if (isInTemplates) {
messageId = 'noRawHtmlInTemplates';
}
context.report({
node,
messageId,
});
}
}
},
};
},
};
};

View File

@@ -0,0 +1,172 @@
/**
* ESLint rule to forbid raw HTML across all UI layers
*
* All HTML must be encapsulated in UI elements from ui/ directory
*
* Rationale:
* - app/ should only contain page/layout components
* - components/ should use ui/ elements for all rendering
* - templates/ should use ui/ elements for all rendering
* - Raw HTML with styling violates separation of concerns
* - UI elements ensure consistency and reusability
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Forbid raw HTML with styling in app/, components/, and templates/ directories',
category: 'Architecture',
recommended: true,
},
fixable: null,
schema: [],
messages: {
noRawHtml: 'Raw HTML with styling is forbidden. Use a UI element from ui/ directory.',
noRawHtmlInApp: 'Raw HTML in app/ is forbidden. Use components/ or ui/ elements.',
noRawHtmlInComponents: 'Raw HTML in components/ should use ui/ elements instead.',
noRawHtmlInTemplates: 'Raw HTML in templates/ should use ui/ elements instead.',
},
},
create(context) {
const filename = context.getFilename();
// Determine which layer we're in
const isInApp = filename.includes('/app/');
const isInComponents = filename.includes('/components/');
const isInTemplates = filename.includes('/templates/');
const isInUi = filename.includes('/ui/');
// Only apply to UI layers (not ui/ itself)
if (!isInApp && !isInComponents && !isInTemplates) {
return {};
}
// HTML tags that should be wrapped in UI elements
const htmlTags = [
'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'button', 'input', 'form', 'label', 'select', 'textarea',
'ul', 'ol', 'li', 'table', 'tr', 'td', 'th', 'thead', 'tbody',
'section', 'article', 'header', 'footer', 'nav', 'aside',
'main', 'aside', 'figure', 'figcaption', 'blockquote', 'code',
'pre', 'a', 'img', 'svg', 'path', 'g', 'rect', 'circle',
'hr', 'br', 'strong', 'em', 'b', 'i', 'u', 'small', 'mark'
];
// UI elements that are allowed (from ui/ directory)
const allowedUiElements = [
'Button', 'Input', 'Form', 'Label', 'Select', 'Textarea',
'Card', 'Container', 'Grid', 'Stack', 'Box', 'Text', 'Heading',
'List', 'Table', 'Section', 'Article', 'Header', 'Footer', 'Nav', 'Aside',
'Link', 'Image', 'Icon', 'Avatar', 'Badge', 'Chip', 'Pill',
'Modal', 'Dialog', 'Toast', 'Notification', 'Alert',
'StepIndicator', 'Loading', 'Spinner', 'Progress'
];
return {
JSXElement(node) {
const openingElement = node.openingElement;
if (openingElement.name.type !== 'JSXIdentifier') return;
const tagName = openingElement.name.name;
// Skip allowed UI elements
if (allowedUiElements.includes(tagName)) {
return;
}
// Check if it's a raw HTML element (lowercase)
if (htmlTags.includes(tagName) && tagName[0] === tagName[0].toLowerCase()) {
// Check for styling attributes
const hasClassName = openingElement.attributes.some(
attr => attr.type === 'JSXAttribute' && attr.name.name === 'className'
);
const hasStyle = openingElement.attributes.some(
attr => attr.type === 'JSXAttribute' && attr.name.name === 'style'
);
// Check for inline event handlers
const hasInlineHandlers = openingElement.attributes.some(
attr => attr.type === 'JSXAttribute' &&
attr.name.name &&
attr.name.name.startsWith('on')
);
// Check for other common attributes that suggest styling/behavior
const hasCommonAttrs = openingElement.attributes.some(
attr => attr.type === 'JSXAttribute' &&
['id', 'role', 'aria-label', 'aria-hidden'].includes(attr.name.name)
);
if (hasClassName || hasStyle || hasInlineHandlers || hasCommonAttrs) {
let messageId = 'noRawHtml';
if (isInApp) {
messageId = 'noRawHtmlInApp';
} else if (isInComponents) {
messageId = 'noRawHtmlInComponents';
} else if (isInTemplates) {
messageId = 'noRawHtmlInTemplates';
}
context.report({
node,
messageId,
});
}
}
},
// Also check for dangerouslySetInnerHTML
JSXAttribute(node) {
if (node.name.name === 'dangerouslySetInnerHTML') {
let messageId = 'noRawHtml';
if (isInApp) {
messageId = 'noRawHtmlInApp';
} else if (isInComponents) {
messageId = 'noRawHtmlInComponents';
} else if (isInTemplates) {
messageId = 'noRawHtmlInTemplates';
}
context.report({
node,
messageId,
});
}
},
// Check for HTML strings in JSX expressions
JSXExpressionContainer(node) {
if (node.expression.type === 'Literal' && typeof node.expression.value === 'string') {
const value = node.expression.value.trim();
// Check if it contains HTML-like content
if (value.includes('<') && value.includes('>') &&
(value.includes('class=') || value.includes('style=') ||
value.match(/<\w+[^>]*>/))) {
let messageId = 'noRawHtml';
if (isInApp) {
messageId = 'noRawHtmlInApp';
} else if (isInComponents) {
messageId = 'noRawHtmlInComponents';
} else if (isInTemplates) {
messageId = 'noRawHtmlInTemplates';
}
context.report({
node,
messageId,
});
}
}
},
};
},
};

View File

@@ -0,0 +1,192 @@
/**
* ESLint Rule: PageQuery Must Map Errors
*
* Enforces the architecture documented in `lib/contracts/page-queries/PageQuery.ts`:
* - PageQueries call Services.
* - Service returns Result<..., DomainError>.
* - PageQuery maps DomainError -> PresentationError via mapToPresentationError.
* - PageQuery returns Result<..., PresentationError>.
*
* Strict enforcement (scoped to service-result error branches):
* - If a page query does `const result = await someService.*(...)` and later does
* `if (result.isErr()) return Result.err(...)`, then the `Result.err(...)`
* must be `Result.err(mapToPresentationError(result.getError()))`.
* - PageQueries must not `return result;` for a service result variable.
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Require PageQueries to map service errors via mapToPresentationError',
category: 'Architecture',
recommended: true,
},
schema: [],
messages: {
mustMapPresentationError:
'PageQueries must map service errors via mapToPresentationError(result.getError()) before returning Result.err(...) - see apps/website/lib/contracts/page-queries/PageQuery.ts',
mustNotReturnServiceResult:
'PageQueries must not return a Service Result directly; map errors and return a Presentation-layer Result instead - see apps/website/lib/contracts/page-queries/PageQuery.ts',
},
},
create(context) {
const filename = context.getFilename();
if (!filename.includes('/lib/page-queries/') || !filename.endsWith('.ts')) {
return {};
}
const resultVarsFromServiceCall = new Set();
const mapToPresentationErrorNames = new Set(['mapToPresentationError']);
function isIdentifier(node, name) {
return node && node.type === 'Identifier' && node.name === name;
}
function isResultErrCall(node) {
return (
node &&
node.type === 'CallExpression' &&
node.callee &&
node.callee.type === 'MemberExpression' &&
!node.callee.computed &&
node.callee.object &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'Result' &&
node.callee.property &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'err'
);
}
function isMapToPresentationErrorCall(node) {
return (
node &&
node.type === 'CallExpression' &&
node.callee &&
node.callee.type === 'Identifier' &&
mapToPresentationErrorNames.has(node.callee.name)
);
}
function isGetErrorCall(node, resultVarName) {
return (
node &&
node.type === 'CallExpression' &&
node.callee &&
node.callee.type === 'MemberExpression' &&
!node.callee.computed &&
node.callee.object &&
isIdentifier(node.callee.object, resultVarName) &&
node.callee.property &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'getError'
);
}
function isAwaitedServiceCall(init) {
if (!init || init.type !== 'AwaitExpression') return false;
const call = init.argument;
if (!call || call.type !== 'CallExpression') return false;
const callee = call.callee;
// `await service.someMethod(...)`
if (callee.type === 'MemberExpression' && callee.object && callee.object.type === 'Identifier') {
return callee.object.name.endsWith('Service');
}
return false;
}
function collectReturnStatements(statementNode, callback) {
if (!statementNode) return;
if (statementNode.type !== 'BlockStatement') {
if (statementNode.type === 'ReturnStatement') callback(statementNode);
return;
}
for (const s of statementNode.body || []) {
if (!s) continue;
if (s.type === 'ReturnStatement') callback(s);
}
}
return {
ImportDeclaration(node) {
const importPath = node.source && node.source.value;
if (typeof importPath !== 'string') return;
const isPresentationErrorImport =
importPath.includes('/lib/contracts/page-queries/PresentationError') ||
importPath.endsWith('lib/contracts/page-queries/PresentationError') ||
importPath.endsWith('contracts/page-queries/PresentationError') ||
importPath.endsWith('contracts/page-queries/PresentationError.ts');
if (!isPresentationErrorImport) return;
for (const spec of node.specifiers || []) {
// import { mapToPresentationError as X } from '...'
if (spec.type === 'ImportSpecifier' && spec.imported && spec.imported.type === 'Identifier') {
if (spec.imported.name === 'mapToPresentationError') {
mapToPresentationErrorNames.add(spec.local.name);
}
}
}
},
VariableDeclarator(node) {
if (!node.id || node.id.type !== 'Identifier') return;
if (!isAwaitedServiceCall(node.init)) return;
resultVarsFromServiceCall.add(node.id.name);
},
ReturnStatement(node) {
if (node.argument && node.argument.type === 'Identifier') {
if (resultVarsFromServiceCall.has(node.argument.name)) {
context.report({ node, messageId: 'mustNotReturnServiceResult' });
}
}
},
IfStatement(node) {
const test = node.test;
if (!test || test.type !== 'CallExpression') return;
const testCallee = test.callee;
if (
!testCallee ||
testCallee.type !== 'MemberExpression' ||
testCallee.computed ||
!testCallee.object ||
testCallee.object.type !== 'Identifier' ||
!testCallee.property ||
testCallee.property.type !== 'Identifier' ||
testCallee.property.name !== 'isErr'
) {
return;
}
const resultVarName = testCallee.object.name;
if (!resultVarsFromServiceCall.has(resultVarName)) return;
collectReturnStatements(node.consequent, (returnNode) => {
const arg = returnNode.argument;
if (!isResultErrCall(arg)) return;
const errArg = arg.arguments && arg.arguments[0];
if (!isMapToPresentationErrorCall(errArg)) {
context.report({ node: returnNode, messageId: 'mustMapPresentationError' });
return;
}
const mappedFrom = errArg.arguments && errArg.arguments[0];
if (!isGetErrorCall(mappedFrom, resultVarName)) {
context.report({ node: returnNode, messageId: 'mustMapPresentationError' });
}
});
},
};
},
};

View File

@@ -1,166 +1,115 @@
/**
* ESLint rule to enforce PageQueries use Builders
* ESLint Rule: Page Query Must Use Builders
*
* PageQueries should not manually transform API DTOs or return them directly.
* They must use Builder classes to transform API DTOs to View Data.
* Ensures page queries use builders to transform DTOs into ViewData
* This prevents DTOs from leaking to the client
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Enforce PageQueries use Builders for data transformation',
description: 'Ensure page queries use builders to transform DTOs into ViewData',
category: 'Page Query',
recommended: true,
},
fixable: null,
schema: [],
messages: {
mustUseBuilder: 'PageQueries must use Builder classes to transform API DTOs to View Data. Found manual transformation or direct API DTO return.',
multipleExports: 'PageQuery files should only export the PageQuery class, not DTOs.',
mustUseBuilder: 'PageQueries must use ViewDataBuilder to transform DTOs - see apps/website/lib/contracts/builders/ViewDataBuilder.ts',
noDirectDtoReturn: 'PageQueries must not return DTOs directly, use builders to create ViewData',
},
schema: [],
},
create(context) {
let inPageQueryExecute = false;
let hasManualTransformation = false;
let hasMultipleExports = false;
let hasBuilderCall = false;
let pageQueryClassName = null;
const filename = context.getFilename();
const isPageQuery = filename.includes('/page-queries/') && filename.endsWith('.ts');
if (!isPageQuery) {
return {};
}
let hasBuilderImport = false;
const builderUsages = [];
const returnStatements = [];
return {
// Track PageQuery class name
ClassDeclaration(node) {
if (node.id && node.id.name && node.id.name.endsWith('PageQuery')) {
pageQueryClassName = node.id.name;
}
},
// Track PageQuery class execute method
MethodDefinition(node) {
if (node.key.type === 'Identifier' &&
node.key.name === 'execute' &&
node.parent.type === 'ClassBody') {
// Check for builder imports
ImportDeclaration(node) {
const importPath = node.source.value;
if (importPath.includes('/builders/view-data/')) {
hasBuilderImport = true;
const classNode = node.parent.parent;
if (classNode && classNode.id && classNode.id.name &&
classNode.id.name.endsWith('PageQuery')) {
inPageQueryExecute = true;
}
}
},
// Detect Builder calls
CallExpression(node) {
if (inPageQueryExecute) {
// Check for Builder.build() or Builder.createViewData()
if (node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier' &&
(node.callee.property.name === 'build' || node.callee.property.name === 'createViewData')) {
// Check if the object is a Builder
if (node.callee.object.type === 'Identifier' &&
(node.callee.object.name.includes('Builder') ||
node.callee.object.name.includes('builder'))) {
hasBuilderCall = true;
// Track which builder is imported
node.specifiers.forEach(spec => {
if (spec.type === 'ImportSpecifier') {
builderUsages.push({
name: spec.imported.name,
localName: spec.local.name,
});
}
}
});
}
},
// Detect object literal assignments (manual transformation)
VariableDeclarator(node) {
if (inPageQueryExecute && node.init && node.init.type === 'ObjectExpression') {
hasManualTransformation = true;
}
},
// Detect object literal in return statements
// Track return statements
ReturnStatement(node) {
if (inPageQueryExecute && node.argument) {
// Direct object literal return
if (node.argument.type === 'ObjectExpression') {
hasManualTransformation = true;
}
// Direct identifier return (likely API DTO)
else if (node.argument.type === 'Identifier' &&
!node.argument.name.includes('ViewData') &&
!node.argument.name.includes('viewData')) {
// This might be returning an API DTO directly
// We'll flag it as manual transformation since no builder was used
if (!hasBuilderCall) {
hasManualTransformation = true;
}
}
// CallExpression like Result.ok(apiDto) or Result.err()
else if (node.argument.type === 'CallExpression') {
// Check if it's a Result call with an identifier argument
const callExpr = node.argument;
if (callExpr.callee.type === 'MemberExpression' &&
callExpr.callee.object.type === 'Identifier' &&
callExpr.callee.object.name === 'Result' &&
callExpr.callee.property.type === 'Identifier' &&
(callExpr.callee.property.name === 'ok' || callExpr.callee.property.name === 'err')) {
// If it's Result.ok(someIdentifier), check if the identifier is likely an API DTO
if (callExpr.callee.property.name === 'ok' &&
callExpr.arguments.length > 0 &&
callExpr.arguments[0].type === 'Identifier') {
const argName = callExpr.arguments[0].name;
// Common API DTO naming patterns
const isApiDto = argName.includes('Dto') ||
argName.includes('api') ||
argName === 'result' ||
argName === 'data';
if (isApiDto && !hasBuilderCall) {
hasManualTransformation = true;
}
}
}
}
}
},
// Track exports
ExportNamedDeclaration(node) {
if (node.declaration) {
if (node.declaration.type === 'ClassDeclaration') {
const className = node.declaration.id?.name;
if (className && !className.endsWith('PageQuery')) {
hasMultipleExports = true;
}
} else if (node.declaration.type === 'InterfaceDeclaration' ||
node.declaration.type === 'TypeAlias') {
hasMultipleExports = true;
}
} else if (node.specifiers && node.specifiers.length > 0) {
hasMultipleExports = true;
}
},
'MethodDefinition:exit'(node) {
if (node.key.type === 'Identifier' && node.key.name === 'execute') {
inPageQueryExecute = false;
if (node.argument) {
returnStatements.push(node);
}
},
'Program:exit'() {
// Only report if no builder was used
if (hasManualTransformation && !hasBuilderCall) {
if (!hasBuilderImport) {
context.report({
node: context.getSourceCode().ast,
messageId: 'mustUseBuilder',
});
return;
}
if (hasMultipleExports) {
context.report({
node: context.getSourceCode().ast,
messageId: 'multipleExports',
});
}
// Check if return statements use builders
returnStatements.forEach(returnNode => {
const returnExpr = returnNode.argument;
// Check if it's a builder call
if (returnExpr && returnExpr.type === 'CallExpression') {
const callee = returnExpr.callee;
// Check if it's a builder method call (e.g., ViewDataBuilder.build())
if (callee.type === 'MemberExpression' &&
callee.property.type === 'Identifier' &&
callee.property.name === 'build') {
// This is good - using a builder
return;
}
// Check if it's a direct Result.ok() with DTO
if (callee.type === 'MemberExpression' &&
callee.object.type === 'Identifier' &&
callee.object.name === 'Result' &&
callee.property.type === 'Identifier' &&
callee.property.name === 'ok') {
// Check if the argument is a variable that might be a DTO
if (returnExpr.arguments && returnExpr.arguments[0]) {
const arg = returnExpr.arguments[0];
// If it's an identifier, check if it's likely a DTO
if (arg.type === 'Identifier') {
const varName = arg.name;
// Common DTO patterns: result, data, dto, apiResult, etc.
if (varName.match(/(result|data|dto|apiResult|response)/i)) {
context.report({
node: returnNode,
messageId: 'noDirectDtoReturn',
});
}
}
}
}
}
});
},
};
},
};
};

View File

@@ -0,0 +1,243 @@
/**
* ESLint Rule: Server Actions Interface Compliance
*
* Ensures server actions follow a specific interface pattern:
* - Must be async functions
* - Must use mutations for business logic
* - Must handle errors properly using Result type
* - Should not contain business logic directly
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Ensure server actions implement the correct interface pattern',
category: 'Server Actions',
recommended: true,
},
messages: {
mustBeAsync: 'Server actions must be async functions',
mustUseMutations: 'Server actions must use mutations for business logic',
mustHandleErrors: 'Server actions must handle errors using Result type',
noBusinessLogic: 'Server actions should be thin wrappers, business logic belongs in mutations',
invalidActionPattern: 'Server actions should follow pattern: async function actionName(input) { const result = await mutation.execute(input); return result; }',
missingMutationUsage: 'Server action must instantiate and call a mutation',
},
schema: [],
},
create(context) {
const filename = context.getFilename();
const isServerActionFile = filename.includes('/app/') &&
(filename.endsWith('.ts') || filename.endsWith('.tsx')) &&
!filename.endsWith('.test.ts') &&
!filename.endsWith('.test.tsx');
if (!isServerActionFile) {
return {};
}
let hasUseServerDirective = false;
const serverActionFunctions = [];
return {
// Check for 'use server' directive
ExpressionStatement(node) {
if (node.expression.type === 'Literal' && node.expression.value === 'use server') {
hasUseServerDirective = true;
}
},
// Track async function declarations
FunctionDeclaration(node) {
if (node.async && node.id && node.id.name) {
serverActionFunctions.push({
name: node.id.name,
node: node,
body: node.body,
params: node.params,
});
}
},
// Track async arrow function exports
ExportNamedDeclaration(node) {
if (node.declaration && node.declaration.type === 'FunctionDeclaration') {
if (node.declaration.async && node.declaration.id) {
serverActionFunctions.push({
name: node.declaration.id.name,
node: node.declaration,
body: node.declaration.body,
params: node.declaration.params,
});
}
} else if (node.declaration && node.declaration.type === 'VariableDeclaration') {
node.declaration.declarations.forEach(decl => {
if (decl.init && decl.init.type === 'ArrowFunctionExpression' && decl.init.async) {
serverActionFunctions.push({
name: decl.id.name,
node: decl.init,
body: decl.init.body,
params: decl.init.params,
});
}
});
}
},
'Program:exit'() {
// Only check files with 'use server' directive
if (!hasUseServerDirective) return;
// Check each server action function
serverActionFunctions.forEach(func => {
// Check if function is async
if (!func.node.async) {
context.report({
node: func.node,
messageId: 'mustBeAsync',
});
}
// Analyze function body for mutation usage and error handling
const bodyAnalysis = analyzeFunctionBody(func.body);
if (!bodyAnalysis.hasMutationUsage) {
context.report({
node: func.node,
messageId: 'missingMutationUsage',
});
}
if (!bodyAnalysis.hasErrorHandling) {
context.report({
node: func.node,
messageId: 'mustHandleErrors',
});
}
if (bodyAnalysis.hasBusinessLogic) {
context.report({
node: func.node,
messageId: 'noBusinessLogic',
});
}
// Check if function follows the expected pattern
if (!bodyAnalysis.followsPattern) {
context.report({
node: func.node,
messageId: 'invalidActionPattern',
});
}
});
},
};
},
};
/**
* Analyze function body to check for proper patterns
*/
function analyzeFunctionBody(body) {
const analysis = {
hasMutationUsage: false,
hasErrorHandling: false,
hasBusinessLogic: false,
followsPattern: false,
};
if (!body || body.type !== 'BlockStatement') {
return analysis;
}
const statements = body.body;
let hasMutationInstantiation = false;
let hasMutationExecute = false;
let hasResultCheck = false;
let hasReturnResult = false;
// Check for mutation usage in a more flexible way
statements.forEach(stmt => {
// Look for: const mutation = new SomeMutation()
if (stmt.type === 'VariableDeclaration') {
stmt.declarations.forEach(decl => {
if (decl.init && decl.init.type === 'NewExpression') {
if (decl.init.callee.type === 'Identifier' &&
decl.init.callee.name.endsWith('Mutation')) {
hasMutationInstantiation = true;
}
}
// Look for: const result = await mutation.execute(...)
if (decl.init && decl.init.type === 'AwaitExpression') {
const awaitExpr = decl.init.argument;
if (awaitExpr.type === 'CallExpression') {
const callee = awaitExpr.callee;
if (callee.type === 'MemberExpression' &&
callee.property.type === 'Identifier' &&
callee.property.name === 'execute') {
// Check if the object is a mutation or result of mutation
if (callee.object.type === 'Identifier') {
const objName = callee.object.name;
// Could be 'mutation' or 'result' (from mutation.execute)
if (objName === 'mutation' || objName === 'result' || objName.endsWith('Mutation')) {
hasMutationExecute = true;
}
}
}
}
}
});
}
// Look for error handling: if (result.isErr())
if (stmt.type === 'IfStatement') {
if (stmt.test.type === 'CallExpression') {
const callee = stmt.test.callee;
if (callee.type === 'MemberExpression' &&
callee.property.type === 'Identifier' &&
(callee.property.name === 'isErr' || callee.property.name === 'isOk')) {
hasResultCheck = true;
}
}
}
// Look for return statements that return Result
if (stmt.type === 'ReturnStatement' && stmt.argument) {
if (stmt.argument.type === 'CallExpression') {
const callee = stmt.argument.callee;
if (callee.type === 'MemberExpression' &&
callee.object.type === 'Identifier' &&
callee.object.name === 'Result') {
hasReturnResult = true;
}
}
}
});
analysis.hasMutationUsage = hasMutationInstantiation && hasMutationExecute;
analysis.hasErrorHandling = hasResultCheck;
analysis.hasReturnResult = hasReturnResult;
// Check if follows pattern: mutation instantiation → execute → error handling → return Result
analysis.followsPattern = analysis.hasMutationUsage &&
analysis.hasErrorHandling &&
analysis.hasReturnResult;
// Check for business logic (direct service calls, complex calculations, etc.)
const hasDirectServiceCall = statements.some(stmt => {
if (stmt.type === 'ExpressionStatement' && stmt.expression.type === 'CallExpression') {
const callee = stmt.expression.callee;
if (callee.type === 'MemberExpression' && callee.object.type === 'Identifier') {
return callee.object.name.endsWith('Service');
}
}
return false;
});
analysis.hasBusinessLogic = hasDirectServiceCall;
return analysis;
}

View File

@@ -0,0 +1,169 @@
/**
* ESLint Rule: Server Actions Must Return Result Type
*
* Ensures server actions return Result<T, E> types instead of arbitrary objects
* This enforces the standardized error handling pattern across all server actions
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Ensure server actions return Result<T, E> types from lib/contracts/Result.ts',
category: 'Server Actions',
recommended: true,
},
messages: {
mustReturnResult: 'Server actions must return Result<T, E> type. Expected: Promise<Result<SuccessType, ErrorType>>',
invalidReturnType: 'Return type must be Result<T, E> or Promise<Result<T, E>>',
missingResultImport: 'Server actions must import Result from @/lib/contracts/Result',
},
schema: [],
},
create(context) {
const filename = context.getFilename();
const isServerActionFile = filename.includes('/app/') &&
(filename.endsWith('.ts') || filename.endsWith('.tsx')) &&
!filename.endsWith('.test.ts') &&
!filename.endsWith('.test.tsx');
if (!isServerActionFile) {
return {};
}
let hasUseServerDirective = false;
let hasResultImport = false;
const functionDeclarations = [];
return {
// Check for 'use server' directive
ExpressionStatement(node) {
if (node.expression.type === 'Literal' && node.expression.value === 'use server') {
hasUseServerDirective = true;
}
},
// Check for Result import
ImportDeclaration(node) {
const importPath = node.source.value;
if (importPath === '@/lib/contracts/Result' || importPath === '@/lib/contracts/Result.ts') {
hasResultImport = true;
}
},
// Track function declarations and exports
FunctionDeclaration(node) {
if (node.async && node.id && node.id.name) {
functionDeclarations.push({
name: node.id.name,
node: node,
returnType: node.returnType,
body: node.body,
});
}
},
// Track arrow function exports
ExportNamedDeclaration(node) {
if (node.declaration && node.declaration.type === 'FunctionDeclaration') {
if (node.declaration.async && node.declaration.id) {
functionDeclarations.push({
name: node.declaration.id.name,
node: node.declaration,
returnType: node.declaration.returnType,
body: node.declaration.body,
});
}
} else if (node.declaration && node.declaration.type === 'VariableDeclaration') {
node.declaration.declarations.forEach(decl => {
if (decl.init && decl.init.type === 'ArrowFunctionExpression' && decl.init.async) {
functionDeclarations.push({
name: decl.id.name,
node: decl.init,
returnType: decl.init.returnType,
body: decl.init.body,
});
}
});
}
},
'Program:exit'() {
// Only check files with 'use server' directive
if (!hasUseServerDirective) return;
// Report if no Result import
if (!hasResultImport) {
context.report({
node: context.getSourceCode().ast,
messageId: 'missingResultImport',
});
}
// Check each server action function
functionDeclarations.forEach(func => {
const returnType = func.returnType;
// Check if return type annotation exists and is correct
if (!returnType) {
context.report({
node: func.node,
messageId: 'mustReturnResult',
});
return;
}
// Helper function to check if a type is Result
function isResultType(typeNode) {
if (!typeNode) return false;
// Direct Result type
if (typeNode.typeName && typeNode.typeName.name === 'Result') {
return true;
}
// Promise<Result<...>>
if (typeNode.typeName && typeNode.typeName.name === 'Promise') {
const typeParams = typeNode.typeParameters || typeNode.typeArguments;
if (typeParams && typeParams.params && typeParams.params[0]) {
return isResultType(typeParams.params[0]);
}
}
return false;
}
// Check if it's a Promise<Result<...>> or Result<...>
if (returnType.typeAnnotation) {
const typeAnnotation = returnType.typeAnnotation;
if (isResultType(typeAnnotation)) {
// Valid: Result<...> or Promise<Result<...>>
return;
} else if (typeAnnotation.type === 'TSUnionType') {
// Check if union contains only Result types
const hasNonResult = typeAnnotation.types.some(type => !isResultType(type));
if (hasNonResult) {
context.report({
node: returnType,
messageId: 'invalidReturnType',
});
}
} else {
context.report({
node: returnType,
messageId: 'invalidReturnType',
});
}
} else {
context.report({
node: returnType,
messageId: 'invalidReturnType',
});
}
});
},
};
},
};

View File

@@ -2,11 +2,12 @@
* ESLint rule to enforce Services follow clean architecture patterns
*
* Services must:
* 1. Return Result types for type-safe error handling
* 2. Use DomainError types (not strings)
* 3. Be classes named *Service
* 4. Create their own dependencies (API Client, Logger, ErrorReporter)
* 5. Have NO constructor parameters (self-contained)
* 1. Explicitly implement Service<TApiDto, TError> interface
* 2. Return Result types for type-safe error handling
* 3. Use DomainError types (not strings)
* 4. Be classes named *Service
* 5. Create their own dependencies (API Client, Logger, ErrorReporter)
* 6. Have NO constructor parameters (self-contained)
*
* Note: Method names can vary (execute(), getSomething(), etc.)
*/
@@ -25,6 +26,7 @@ module.exports = {
mustReturnResult: 'Service methods must return Promise<Result<T, DomainError>>',
mustUseDomainError: 'Error types must be DomainError objects, not strings',
noConstructorParams: 'Services must be self-contained. Constructor cannot have parameters. Dependencies should be created inside the constructor.',
mustImplementContract: 'Services must explicitly implement Service<TApiDto, TError> interface',
},
},
@@ -48,6 +50,27 @@ module.exports = {
return; // Not a service class
}
// Check if class implements Service interface
// The implements clause can be:
// - implements Service
// - implements Service<TApiDto, TError>
// The AST structure is:
// impl.expression: Identifier { name: 'Service' }
// impl.typeArguments: TSTypeParameterInstantiation (for generic types)
const implementsService = node.implements && node.implements.some(impl => {
if (impl.expression.type === 'Identifier') {
return impl.expression.name === 'Service';
}
return false;
});
if (!implementsService) {
context.report({
node: node.id,
messageId: 'mustImplementContract',
});
}
// Check all methods for Result return types
node.body.body.forEach(member => {
if (member.type === 'MethodDefinition' &&

View File

@@ -5,42 +5,7 @@
*/
module.exports = {
// Rule 1: Services must be marked with @server-safe or @client-only
'services-must-be-marked': {
meta: {
type: 'problem',
docs: {
description: 'Enforce service safety marking',
category: 'Services',
},
messages: {
message: 'Services must be explicitly marked with @server-safe or @client-only comment - see apps/website/lib/contracts/services/Service.ts',
},
},
create(context) {
return {
Program(node) {
const filename = context.getFilename();
if (filename.includes('/lib/services/') && filename.endsWith('.ts')) {
const sourceCode = context.getSourceCode();
const text = sourceCode.getText();
const hasServerSafe = text.includes('@server-safe');
const hasClientOnly = text.includes('@client-only');
if (!hasServerSafe && !hasClientOnly) {
context.report({
loc: { line: 1, column: 0 },
messageId: 'message',
});
}
}
},
};
},
},
// Rule 2: No external API calls in services
// Rule 1: No external API calls in services
'no-external-api-in-services': {
meta: {
type: 'problem',
@@ -87,7 +52,7 @@ module.exports = {
},
},
// Rule 3: Services must be pure functions
// Rule 2: Services must be pure functions
'services-must-be-pure': {
meta: {
type: 'problem',

View File

@@ -140,63 +140,122 @@ module.exports = {
},
},
create(context) {
// Helper to recursively check if type contains ViewData
function typeContainsViewData(typeNode) {
if (!typeNode) return false;
// Check direct type name
if (typeNode.type === 'TSTypeReference' &&
typeNode.typeName &&
typeNode.typeName.name &&
typeNode.typeName.name.includes('ViewData')) {
return true;
}
// Check nested in object type
if (typeNode.type === 'TSTypeLiteral' && typeNode.members) {
for (const member of typeNode.members) {
if (member.type === 'TSPropertySignature' &&
member.typeAnnotation &&
typeContainsViewData(member.typeAnnotation.typeAnnotation)) {
return true;
const sourceCode = context.getSourceCode();
function isTemplateExportFunction(node) {
const functionName = node.id && node.id.type === 'Identifier' ? node.id.name : null;
if (!functionName || !functionName.endsWith('Template')) return false;
// Only enforce for exported template component functions.
return node.parent && node.parent.type === 'ExportNamedDeclaration';
}
function getTypeReferenceName(typeNode) {
if (!typeNode || typeNode.type !== 'TSTypeReference') return null;
const typeName = typeNode.typeName;
if (!typeName || typeName.type !== 'Identifier') return null;
return typeName.name;
}
function findLocalTypeDeclaration(typeName) {
const programBody = sourceCode.ast && sourceCode.ast.body ? sourceCode.ast.body : [];
for (const stmt of programBody) {
if (!stmt) continue;
// interface Foo { ... }
if (stmt.type === 'TSInterfaceDeclaration' && stmt.id && stmt.id.type === 'Identifier') {
if (stmt.id.name === typeName) return stmt;
}
// type Foo = ...
if (stmt.type === 'TSTypeAliasDeclaration' && stmt.id && stmt.id.type === 'Identifier') {
if (stmt.id.name === typeName) return stmt;
}
// export interface/type Foo ...
if (stmt.type === 'ExportNamedDeclaration' && stmt.declaration) {
const decl = stmt.declaration;
if (decl.type === 'TSInterfaceDeclaration' && decl.id && decl.id.type === 'Identifier' && decl.id.name === typeName) {
return decl;
}
if (decl.type === 'TSTypeAliasDeclaration' && decl.id && decl.id.type === 'Identifier' && decl.id.name === typeName) {
return decl;
}
}
}
// Check union/intersection types
if (typeNode.type === 'TSUnionType' || typeNode.type === 'TSIntersectionType') {
return typeNode.types.some(t => typeContainsViewData(t));
return null;
}
// Helper to recursively check if type contains ViewData (including by resolving local interface/type aliases).
function typeContainsViewData(typeNode, seenTypeNames = new Set()) {
if (!typeNode) return false;
// Direct type name includes ViewData (e.g. ProfileViewData)
if (typeNode.type === 'TSTypeReference' && typeNode.typeName && typeNode.typeName.type === 'Identifier') {
const name = typeNode.typeName.name;
if (name.includes('ViewData')) return true;
// If the param is typed as a local Props interface/type, resolve it and inspect its members.
if (!seenTypeNames.has(name)) {
seenTypeNames.add(name);
const decl = findLocalTypeDeclaration(name);
if (decl && decl.type === 'TSInterfaceDeclaration') {
for (const member of decl.body.body || []) {
if (member.type === 'TSPropertySignature' && member.typeAnnotation) {
if (typeContainsViewData(member.typeAnnotation.typeAnnotation, seenTypeNames)) return true;
}
}
}
if (decl && decl.type === 'TSTypeAliasDeclaration') {
if (typeContainsViewData(decl.typeAnnotation, seenTypeNames)) return true;
}
}
}
// Nested in object type
if (typeNode.type === 'TSTypeLiteral' && typeNode.members) {
for (const member of typeNode.members) {
if (member.type === 'TSPropertySignature' && member.typeAnnotation) {
if (typeContainsViewData(member.typeAnnotation.typeAnnotation, seenTypeNames)) return true;
}
}
}
// Union/intersection types
if (typeNode.type === 'TSUnionType' || typeNode.type === 'TSIntersectionType') {
return typeNode.types.some((t) => typeContainsViewData(t, seenTypeNames));
}
return false;
}
return {
FunctionDeclaration(node) {
if (!isTemplateExportFunction(node)) return;
if (node.params.length === 0) {
context.report({
node,
messageId: 'message',
});
context.report({ node, messageId: 'message' });
return;
}
const firstParam = node.params[0];
// `function FooTemplate({ ... }: Props)` -> the type annotation is on the parameter, not on the destructured properties.
if (!firstParam.typeAnnotation || !firstParam.typeAnnotation.typeAnnotation) {
context.report({
node,
messageId: 'message',
});
context.report({ node, messageId: 'message' });
return;
}
const typeAnnotation = firstParam.typeAnnotation.typeAnnotation;
if (!typeContainsViewData(typeAnnotation)) {
context.report({
node,
messageId: 'message',
});
const firstParamType = firstParam.typeAnnotation.typeAnnotation;
// If it's a reference to a local type/interface, we handle it in typeContainsViewData().
const refName = getTypeReferenceName(firstParamType);
if (refName && refName.includes('ViewData')) return;
if (!typeContainsViewData(firstParamType)) {
context.report({ node, messageId: 'message' });
}
},
};