website refactor
This commit is contained in:
@@ -26,6 +26,9 @@ const modelTaxonomyRules = require('./model-taxonomy-rules');
|
||||
const filenameRules = require('./filename-rules');
|
||||
const componentNoDataManipulation = require('./component-no-data-manipulation');
|
||||
const presenterPurity = require('./presenter-purity');
|
||||
const mutationContract = require('./mutation-contract');
|
||||
const serverActionsMustUseMutations = require('./server-actions-must-use-mutations');
|
||||
const viewDataLocation = require('./view-data-location');
|
||||
|
||||
module.exports = {
|
||||
rules: {
|
||||
@@ -90,6 +93,15 @@ module.exports = {
|
||||
|
||||
// Component Data Manipulation Rules
|
||||
'component-no-data-manipulation': componentNoDataManipulation,
|
||||
|
||||
// Mutation Rules
|
||||
'mutation-contract': mutationContract,
|
||||
|
||||
// Server Actions Rules
|
||||
'server-actions-must-use-mutations': serverActionsMustUseMutations,
|
||||
|
||||
// View Data Rules
|
||||
'view-data-location': viewDataLocation,
|
||||
},
|
||||
|
||||
// Configurations for different use cases
|
||||
@@ -155,6 +167,15 @@ module.exports = {
|
||||
// Filename
|
||||
'gridpilot-rules/filename-presenter-match': 'error',
|
||||
'gridpilot-rules/filename-service-match': 'error',
|
||||
|
||||
// Mutations
|
||||
'gridpilot-rules/mutation-contract': 'error',
|
||||
|
||||
// Server Actions
|
||||
'gridpilot-rules/server-actions-must-use-mutations': 'error',
|
||||
|
||||
// View Data
|
||||
'gridpilot-rules/view-data-location': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
132
apps/website/eslint-rules/mutation-contract.js
Normal file
132
apps/website/eslint-rules/mutation-contract.js
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* ESLint Rule: Mutation Contract
|
||||
*
|
||||
* Ensures mutations implement the Mutation contract
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Ensure mutations implement the Mutation contract',
|
||||
category: 'Mutation Contract',
|
||||
recommended: true,
|
||||
},
|
||||
messages: {
|
||||
noMutationContract: 'Mutations must implement the Mutation interface from lib/contracts/mutations/Mutation.ts',
|
||||
missingExecute: 'Mutations must have an execute method that takes input and returns Promise',
|
||||
wrongExecuteSignature: 'Execute method must have signature: execute(input: TInput): Promise<TOutput>',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
|
||||
// Only apply to mutation files
|
||||
if (!filename.includes('/lib/mutations/') || !filename.endsWith('.ts')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let hasMutationImport = false;
|
||||
let hasExecuteMethod = false;
|
||||
let implementsMutation = false;
|
||||
let executeMethodNode = null;
|
||||
|
||||
return {
|
||||
// Check for Mutation import
|
||||
ImportDeclaration(node) {
|
||||
if (node.source.value === '@/lib/contracts/mutations/Mutation') {
|
||||
hasMutationImport = true;
|
||||
}
|
||||
},
|
||||
|
||||
// Check for implements clause
|
||||
ClassDeclaration(node) {
|
||||
if (node.implements) {
|
||||
node.implements.forEach(impl => {
|
||||
if (impl.type === 'Identifier' && impl.name === 'Mutation') {
|
||||
implementsMutation = true;
|
||||
}
|
||||
if (impl.type === 'TSExpressionWithTypeArguments' &&
|
||||
impl.expression.type === 'Identifier' &&
|
||||
impl.expression.name === 'Mutation') {
|
||||
implementsMutation = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Check for execute method
|
||||
MethodDefinition(node) {
|
||||
if (node.key.type === 'Identifier' && node.key.name === 'execute') {
|
||||
hasExecuteMethod = true;
|
||||
executeMethodNode = node;
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
// Skip if file doesn't look like a mutation
|
||||
const isMutationFile = filename.includes('/lib/mutations/') &&
|
||||
filename.endsWith('.ts') &&
|
||||
!filename.endsWith('.test.ts');
|
||||
|
||||
if (!isMutationFile) return;
|
||||
|
||||
// Check if it's a class-based mutation
|
||||
const hasClass = filename.includes('/lib/mutations/') &&
|
||||
!filename.endsWith('.test.ts');
|
||||
|
||||
if (hasClass && !hasExecuteMethod) {
|
||||
if (executeMethodNode) {
|
||||
context.report({
|
||||
node: executeMethodNode,
|
||||
messageId: 'missingExecute',
|
||||
});
|
||||
} else {
|
||||
// Find the class node to report on
|
||||
const sourceCode = context.getSourceCode();
|
||||
const classNode = sourceCode.ast.body.find(node =>
|
||||
node.type === 'ClassDeclaration' &&
|
||||
node.id &&
|
||||
node.id.name.endsWith('Mutation')
|
||||
);
|
||||
if (classNode) {
|
||||
context.report({
|
||||
node: classNode,
|
||||
messageId: 'missingExecute',
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for contract implementation (if it's a class)
|
||||
if (hasClass && !implementsMutation && hasMutationImport) {
|
||||
// Find the class node to report on
|
||||
const sourceCode = context.getSourceCode();
|
||||
const classNode = sourceCode.ast.body.find(node =>
|
||||
node.type === 'ClassDeclaration' &&
|
||||
node.id &&
|
||||
node.id.name.endsWith('Mutation')
|
||||
);
|
||||
if (classNode) {
|
||||
context.report({
|
||||
node: classNode,
|
||||
messageId: 'noMutationContract',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check execute method signature
|
||||
if (executeMethodNode && executeMethodNode.value.params.length === 0) {
|
||||
context.report({
|
||||
node: executeMethodNode,
|
||||
messageId: 'wrongExecuteSignature',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -75,10 +75,18 @@ module.exports = {
|
||||
ClassDeclaration(node) {
|
||||
const className = node.id?.name;
|
||||
if (className && className.endsWith('PageQuery')) {
|
||||
if (!node.implements ||
|
||||
!node.implements.some(impl =>
|
||||
impl.expression.type === 'GenericIdentifier' &&
|
||||
impl.expression.name === 'PageQuery')) {
|
||||
const hasPageQueryImpl = node.implements && node.implements.some(impl => {
|
||||
// Handle different AST node types for generic interfaces
|
||||
if (impl.expression.type === 'TSExpressionWithTypeArguments') {
|
||||
return impl.expression.expression.name === 'PageQuery';
|
||||
}
|
||||
if (impl.expression.type === 'Identifier') {
|
||||
return impl.expression.name === 'PageQuery';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!hasPageQueryImpl) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'message',
|
||||
|
||||
89
apps/website/eslint-rules/page-query-use-builder.js
Normal file
89
apps/website/eslint-rules/page-query-use-builder.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* ESLint Rule: Page Query Must Use Builder
|
||||
*
|
||||
* Ensures page queries use builders to map their results
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Ensure page queries use builders to map their results',
|
||||
category: 'Page Query',
|
||||
recommended: true,
|
||||
},
|
||||
messages: {
|
||||
mustUseBuilder: 'Page queries must use Builders to map Page DTO to View Data/View Model. See apps/website/docs/architecture/write/BUILDERS.md',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
|
||||
// Only apply to page query files
|
||||
if (!filename.includes('/lib/page-queries/') || !filename.endsWith('.ts')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let hasBuilderImport = false;
|
||||
let hasReturnStatement = false;
|
||||
|
||||
return {
|
||||
// Check imports for builder
|
||||
ImportDeclaration(node) {
|
||||
const importPath = node.source.value;
|
||||
if (importPath.includes('/lib/builders/')) {
|
||||
hasBuilderImport = true;
|
||||
}
|
||||
},
|
||||
|
||||
// Check for return statements
|
||||
ReturnStatement(node) {
|
||||
hasReturnStatement = true;
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
// Skip if file doesn't look like a page query
|
||||
const isPageQueryFile = filename.includes('/lib/page-queries/') &&
|
||||
filename.endsWith('.ts') &&
|
||||
!filename.endsWith('.test.ts') &&
|
||||
!filename.includes('/result/');
|
||||
|
||||
if (!isPageQueryFile) return;
|
||||
|
||||
// Check if it's a class-based page query
|
||||
const sourceCode = context.getSourceCode();
|
||||
const classNode = sourceCode.ast.body.find(node =>
|
||||
node.type === 'ClassDeclaration' &&
|
||||
node.id &&
|
||||
node.id.name.endsWith('PageQuery')
|
||||
);
|
||||
|
||||
if (!classNode) return;
|
||||
|
||||
// Check if the class has an execute method
|
||||
const executeMethod = classNode.body.body.find(member =>
|
||||
member.type === 'MethodDefinition' &&
|
||||
member.key.type === 'Identifier' &&
|
||||
member.key.name === 'execute'
|
||||
);
|
||||
|
||||
if (!executeMethod) return;
|
||||
|
||||
// Check if the execute method uses a builder
|
||||
// Look for builder usage in the method body
|
||||
const methodBody = executeMethod.value.body;
|
||||
if (!methodBody) return;
|
||||
|
||||
// Check if there's a builder import
|
||||
if (!hasBuilderImport) {
|
||||
context.report({
|
||||
node: classNode,
|
||||
messageId: 'mustUseBuilder',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -275,7 +275,16 @@ module.exports = {
|
||||
create(context) {
|
||||
return {
|
||||
FunctionDeclaration(node) {
|
||||
if (!node.id.name.startsWith('assert') &&
|
||||
// Skip if this is the main component (default export or ends with Page/Template)
|
||||
const filename = context.getFilename();
|
||||
const isMainComponent =
|
||||
(node.parent && node.parent.type === 'ExportDefaultDeclaration') ||
|
||||
(node.id && node.id.name && (node.id.name.endsWith('Page') || node.id.name.endsWith('Template') || node.id.name.endsWith('Component')));
|
||||
|
||||
if (isMainComponent) return;
|
||||
|
||||
// Only flag nested helper functions
|
||||
if (!node.id.name.startsWith('assert') &&
|
||||
!node.id.name.startsWith('invariant') &&
|
||||
!isInComment(node)) {
|
||||
context.report({
|
||||
@@ -285,9 +294,16 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
VariableDeclarator(node) {
|
||||
if (node.init &&
|
||||
// Skip if this is the main component
|
||||
const isMainComponent =
|
||||
(node.parent && node.parent.parent && node.parent.parent.type === 'ExportDefaultDeclaration') ||
|
||||
(node.id && node.id.name && (node.id.name.endsWith('Page') || node.id.name.endsWith('Template') || node.id.name.endsWith('Component')));
|
||||
|
||||
if (isMainComponent) return;
|
||||
|
||||
if (node.init &&
|
||||
(node.init.type === 'FunctionExpression' || node.init.type === 'ArrowFunctionExpression') &&
|
||||
!node.id.name.startsWith('assert') &&
|
||||
!node.id.name.startsWith('assert') &&
|
||||
!node.id.name.startsWith('invariant') &&
|
||||
!isInComment(node)) {
|
||||
context.report({
|
||||
|
||||
130
apps/website/eslint-rules/server-actions-must-use-mutations.js
Normal file
130
apps/website/eslint-rules/server-actions-must-use-mutations.js
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* ESLint Rule: Server Actions Must Use Mutations
|
||||
*
|
||||
* Ensures server actions use mutations instead of direct service calls
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Ensure server actions use mutations instead of direct service calls',
|
||||
category: 'Server Actions',
|
||||
recommended: true,
|
||||
},
|
||||
messages: {
|
||||
mustUseMutations: 'Server actions must use Mutations, not direct Service or API Client calls. See apps/website/docs/architecture/write/MUTATIONS.md',
|
||||
noDirectService: 'Direct service calls in server actions are not allowed',
|
||||
noMutationUsage: 'Server actions should instantiate and call mutations',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
const isServerAction = filename.includes('/app/') &&
|
||||
(filename.endsWith('.ts') || filename.endsWith('.tsx')) &&
|
||||
!filename.endsWith('.test.ts') &&
|
||||
!filename.endsWith('.test.tsx');
|
||||
|
||||
if (!isServerAction) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let hasUseServerDirective = false;
|
||||
let hasServiceImport = false;
|
||||
let hasApiClientImport = false;
|
||||
let hasMutationImport = false;
|
||||
const newExpressions = [];
|
||||
const callExpressions = [];
|
||||
|
||||
return {
|
||||
// Check for 'use server' directive
|
||||
ExpressionStatement(node) {
|
||||
if (node.expression.type === 'Literal' && node.expression.value === 'use server') {
|
||||
hasUseServerDirective = true;
|
||||
}
|
||||
},
|
||||
|
||||
// Check imports
|
||||
ImportDeclaration(node) {
|
||||
const importPath = node.source.value;
|
||||
|
||||
if (importPath.includes('/lib/services/')) {
|
||||
hasServiceImport = true;
|
||||
}
|
||||
if (importPath.includes('/lib/api/') || importPath.includes('/api/')) {
|
||||
hasApiClientImport = true;
|
||||
}
|
||||
if (importPath.includes('/lib/mutations/')) {
|
||||
hasMutationImport = true;
|
||||
}
|
||||
},
|
||||
|
||||
// Track all NewExpression (instantiations)
|
||||
NewExpression(node) {
|
||||
newExpressions.push(node);
|
||||
},
|
||||
|
||||
// Track all CallExpression (method calls)
|
||||
CallExpression(node) {
|
||||
callExpressions.push(node);
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
// Only check files with 'use server' directive
|
||||
if (!hasUseServerDirective) return;
|
||||
|
||||
// Check for direct service/API client instantiation
|
||||
const hasDirectServiceInstantiation = newExpressions.some(node => {
|
||||
if (node.callee.type === 'Identifier') {
|
||||
const calleeName = node.callee.name;
|
||||
return calleeName.endsWith('Service') || calleeName.endsWith('ApiClient') || calleeName.endsWith('Client');
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Check for direct service method calls
|
||||
const hasDirectServiceCall = callExpressions.some(node => {
|
||||
if (node.callee.type === 'MemberExpression' &&
|
||||
node.callee.object.type === 'Identifier') {
|
||||
const objName = node.callee.object.name;
|
||||
return objName.endsWith('Service') || objName.endsWith('ApiClient');
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Check if mutations are being used
|
||||
const hasMutationUsage = newExpressions.some(node => {
|
||||
if (node.callee.type === 'Identifier') {
|
||||
return node.callee.name.endsWith('Mutation');
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Report violations
|
||||
if (hasDirectServiceInstantiation && !hasMutationUsage) {
|
||||
context.report({
|
||||
node: null,
|
||||
messageId: 'mustUseMutations',
|
||||
});
|
||||
}
|
||||
|
||||
if (hasDirectServiceCall) {
|
||||
context.report({
|
||||
node: null,
|
||||
messageId: 'noDirectService',
|
||||
});
|
||||
}
|
||||
|
||||
// If imports exist but no mutation usage
|
||||
if ((hasServiceImport || hasApiClientImport) && !hasMutationImport) {
|
||||
context.report({
|
||||
node: null,
|
||||
messageId: 'mustUseMutations',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -140,11 +140,59 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check union/intersection types
|
||||
if (typeNode.type === 'TSUnionType' || typeNode.type === 'TSIntersectionType') {
|
||||
return typeNode.types.some(t => typeContainsViewData(t));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
FunctionDeclaration(node) {
|
||||
if (node.params.length === 0 ||
|
||||
!node.params[0].typeAnnotation ||
|
||||
!node.params[0].typeAnnotation.typeAnnotation.type.includes('ViewData')) {
|
||||
if (node.params.length === 0) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'message',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const firstParam = node.params[0];
|
||||
if (!firstParam.typeAnnotation || !firstParam.typeAnnotation.typeAnnotation) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'message',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const typeAnnotation = firstParam.typeAnnotation.typeAnnotation;
|
||||
|
||||
if (!typeContainsViewData(typeAnnotation)) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'message',
|
||||
@@ -170,9 +218,24 @@ module.exports = {
|
||||
create(context) {
|
||||
return {
|
||||
ExportNamedDeclaration(node) {
|
||||
if (node.declaration &&
|
||||
(node.declaration.type === 'FunctionDeclaration' ||
|
||||
if (node.declaration &&
|
||||
(node.declaration.type === 'FunctionDeclaration' ||
|
||||
node.declaration.type === 'VariableDeclaration')) {
|
||||
|
||||
// Get the function/variable name
|
||||
const name = node.declaration.id?.name;
|
||||
|
||||
// Allow the main template component (ends with Template)
|
||||
if (name && name.endsWith('Template')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow default exports
|
||||
if (node.declaration.type === 'VariableDeclaration' &&
|
||||
node.declaration.declarations.some(d => d.id.type === 'Identifier' && d.id.name.endsWith('Template'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'message',
|
||||
|
||||
50
apps/website/eslint-rules/view-data-location.js
Normal file
50
apps/website/eslint-rules/view-data-location.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* ESLint Rule: ViewData Location
|
||||
*
|
||||
* Ensures ViewData types are in lib/view-data/, not templates/
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Ensure ViewData types are in lib/view-data/, not templates/',
|
||||
category: 'File Structure',
|
||||
recommended: true,
|
||||
},
|
||||
messages: {
|
||||
wrongLocation: 'ViewData types must be in lib/view-data/, not templates/. See apps/website/docs/architecture/website/VIEW_DATA.md',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
const importPath = node.source.value;
|
||||
|
||||
// Check for ViewData imports from templates
|
||||
if (importPath.includes('/templates/') &&
|
||||
importPath.includes('ViewData')) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'wrongLocation',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// Also check if the file itself is a ViewData in wrong location
|
||||
Program(node) {
|
||||
if (filename.includes('/templates/') &&
|
||||
filename.endsWith('ViewData.ts')) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'wrongLocation',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user