website refactor
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
227
apps/website/eslint-rules/mutation-must-map-errors.js
Normal file
227
apps/website/eslint-rules/mutation-must-map-errors.js
Normal 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' });
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
211
apps/website/eslint-rules/mutation-must-use-builders.js
Normal file
211
apps/website/eslint-rules/mutation-must-use-builders.js
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
48
apps/website/eslint-rules/no-console.js
Normal file
48
apps/website/eslint-rules/no-console.js
Normal 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' });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
56
apps/website/eslint-rules/no-direct-process-env.js
Normal file
56
apps/website/eslint-rules/no-direct-process-env.js
Normal 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' });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
57
apps/website/eslint-rules/no-next-cookies-in-pages.js
Normal file
57
apps/website/eslint-rules/no-next-cookies-in-pages.js
Normal 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' });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
172
apps/website/eslint-rules/no-raw-html.js
Normal file
172
apps/website/eslint-rules/no-raw-html.js
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
192
apps/website/eslint-rules/page-query-must-map-errors.js
Normal file
192
apps/website/eslint-rules/page-query-must-map-errors.js
Normal 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' });
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
243
apps/website/eslint-rules/server-actions-interface.js
Normal file
243
apps/website/eslint-rules/server-actions-interface.js
Normal 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;
|
||||
}
|
||||
169
apps/website/eslint-rules/server-actions-return-result.js
Normal file
169
apps/website/eslint-rules/server-actions-return-result.js
Normal 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',
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user