do to formatters

This commit is contained in:
2026-01-24 01:22:43 +01:00
parent 891b3cf0ee
commit 705f9685b5
18 changed files with 361 additions and 760 deletions

View File

@@ -1,87 +0,0 @@
/**
* ESLint rules for Display Object Guardrails
*
* Enforces display object boundaries and purity
*/
module.exports = {
// Rule 1: No IO in display objects
'no-io-in-display-objects': {
meta: {
type: 'problem',
docs: {
description: 'Forbid IO imports in display objects',
category: 'Display Objects',
},
messages: {
message: 'DisplayObjects cannot import from api, services, page-queries, or view-models - see apps/website/lib/contracts/display-objects/DisplayObject.ts',
},
},
create(context) {
const forbiddenPaths = [
'@/lib/api/',
'@/lib/services/',
'@/lib/page-queries/',
'@/lib/view-models/',
'@/lib/presenters/',
];
return {
ImportDeclaration(node) {
const importPath = node.source.value;
if (forbiddenPaths.some(path => importPath.includes(path)) &&
!isInComment(node)) {
context.report({
node,
messageId: 'message',
});
}
},
};
},
},
// Rule 2: No non-class display exports
'no-non-class-display-exports': {
meta: {
type: 'problem',
docs: {
description: 'Forbid non-class exports in display objects',
category: 'Display Objects',
},
messages: {
message: 'Display Objects must be class-based and export only classes - see apps/website/lib/contracts/display-objects/DisplayObject.ts',
},
},
create(context) {
return {
ExportNamedDeclaration(node) {
if (node.declaration &&
(node.declaration.type === 'FunctionDeclaration' ||
(node.declaration.type === 'VariableDeclaration' &&
!node.declaration.declarations.some(d => d.init && d.init.type === 'ClassExpression')))) {
context.report({
node,
messageId: 'message',
});
}
},
ExportDefaultDeclaration(node) {
if (node.declaration &&
node.declaration.type !== 'ClassDeclaration' &&
node.declaration.type !== 'ClassExpression') {
context.report({
node,
messageId: 'message',
});
}
},
};
},
},
};
// Helper functions
function isInComment(node) {
return false;
}

View File

@@ -0,0 +1,138 @@
/**
* ESLint rules for Formatter/Display Guardrails
*
* Enforces boundaries and purity for Formatters and Display Objects
*/
module.exports = {
// Rule 1: No IO in formatters/displays
'no-io-in-display-objects': {
meta: {
type: 'problem',
docs: {
description: 'Forbid IO imports in formatters and displays',
category: 'Formatters',
},
messages: {
message: 'Formatters/Displays cannot import from api, services, page-queries, or view-models - see apps/website/lib/contracts/formatters/Formatter.ts',
},
},
create(context) {
const forbiddenPaths = [
'@/lib/api/',
'@/lib/services/',
'@/lib/page-queries/',
'@/lib/view-models/',
'@/lib/presenters/',
];
return {
ImportDeclaration(node) {
const importPath = node.source.value;
if (forbiddenPaths.some(path => importPath.includes(path)) &&
!isInComment(node)) {
context.report({
node,
messageId: 'message',
});
}
},
};
},
},
// Rule 2: No non-class display exports
'no-non-class-display-exports': {
meta: {
type: 'problem',
docs: {
description: 'Forbid non-class exports in formatters and displays',
category: 'Formatters',
},
messages: {
message: 'Formatters and Displays must be class-based and export only classes - see apps/website/lib/contracts/formatters/Formatter.ts',
},
},
create(context) {
return {
ExportNamedDeclaration(node) {
if (node.declaration &&
(node.declaration.type === 'FunctionDeclaration' ||
(node.declaration.type === 'VariableDeclaration' &&
!node.declaration.declarations.some(d => d.init && d.init.type === 'ClassExpression')))) {
context.report({
node,
messageId: 'message',
});
}
},
ExportDefaultDeclaration(node) {
if (node.declaration &&
node.declaration.type !== 'ClassDeclaration' &&
node.declaration.type !== 'ClassExpression') {
context.report({
node,
messageId: 'message',
});
}
},
};
},
},
// Rule 3: Formatters must return primitives
'formatters-must-return-primitives': {
meta: {
type: 'problem',
docs: {
description: 'Enforce that Formatters return primitive values for ViewData compatibility',
category: 'Formatters',
},
messages: {
message: 'Formatters used in ViewDataBuilders must return primitive values (string, number, boolean, null) - see apps/website/lib/contracts/formatters/Formatter.ts',
},
},
create(context) {
const filename = context.getFilename();
const isViewDataBuilder = filename.includes('/lib/builders/view-data/');
if (!isViewDataBuilder) return {};
return {
CallExpression(node) {
// Check if calling a Formatter/Display method
if (node.callee.type === 'MemberExpression' &&
node.callee.object.name &&
(node.callee.object.name.endsWith('Formatter') || node.callee.object.name.endsWith('Display'))) {
// If it's inside a ViewData object literal, it must be a primitive return
let parent = node.parent;
while (parent) {
if (parent.type === 'Property' && parent.parent.type === 'ObjectExpression') {
// This is a property in an object literal (likely ViewData)
// We can't easily check the return type of the method at lint time without type info,
// but we can enforce that it's not the whole object being assigned.
if (node.callee.property.name === 'format' || node.callee.property.name.startsWith('format')) {
// Good: calling a format method
return;
}
// If they are assigning the result of a non-format method, warn
context.report({
node,
messageId: 'message',
});
}
parent = parent.parent;
}
}
},
};
},
},
};
// Helper functions
function isInComment(node) {
return false;
}

View File

@@ -17,7 +17,7 @@
const presenterContract = require('./presenter-contract');
const rscBoundaryRules = require('./rsc-boundary-rules');
const templatePurityRules = require('./template-purity-rules');
const displayObjectRules = require('./display-object-rules');
const displayObjectRules = require('./formatter-rules');
const pageQueryRules = require('./page-query-rules');
const servicesRules = require('./services-rules');
const clientOnlyRules = require('./client-only-rules');
@@ -30,7 +30,6 @@ const mutationContract = require('./mutation-contract');
const serverActionsMustUseMutations = require('./server-actions-must-use-mutations');
const viewDataLocation = require('./view-data-location');
const viewDataBuilderContract = require('./view-data-builder-contract');
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');
@@ -48,7 +47,6 @@ const serverActionsInterface = require('./server-actions-interface');
const noDisplayObjectsInUi = require('./no-display-objects-in-ui');
const viewDataBuilderImplements = require('./view-data-builder-implements');
const viewDataBuilderImports = require('./view-data-builder-imports');
const viewModelBuilderImplements = require('./view-model-builder-implements');
const viewDataImplements = require('./view-data-implements');
const viewModelImplements = require('./view-model-implements');
const viewModelTaxonomy = require('./view-model-taxonomy');
@@ -85,6 +83,7 @@ module.exports = {
// Display Object Rules
'display-no-domain-models': displayObjectRules['no-io-in-display-objects'],
'display-no-business-logic': displayObjectRules['no-non-class-display-exports'],
'formatters-must-return-primitives': displayObjectRules['formatters-must-return-primitives'],
'no-display-objects-in-ui': noDisplayObjectsInUi,
// Page Query Rules
@@ -139,8 +138,6 @@ module.exports = {
'view-data-implements': viewDataImplements,
// View Model Rules
'view-model-builder-contract': viewModelBuilderContract,
'view-model-builder-implements': viewModelBuilderImplements,
'view-model-implements': viewModelImplements,
'view-model-taxonomy': viewModelTaxonomy,
@@ -222,6 +219,7 @@ module.exports = {
// Display Objects
'gridpilot-rules/display-no-domain-models': 'error',
'gridpilot-rules/display-no-business-logic': 'error',
'gridpilot-rules/formatters-must-return-primitives': 'error',
'gridpilot-rules/no-display-objects-in-ui': 'error',
// Page Queries

View File

@@ -1,105 +0,0 @@
/**
* ESLint rule to enforce View Model Builder contract
*
* View Model Builders must:
* 1. Be classes named *ViewModelBuilder
* 2. Have a static build() method
* 3. Use 'satisfies ViewModelBuilder<...>' for static enforcement
* 4. Accept View Data as parameter
* 5. Return View Model
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Enforce View Model Builder contract',
category: 'Builders',
recommended: true,
},
fixable: null,
schema: [],
messages: {
notAClass: 'View Model Builders must be classes named *ViewModelBuilder',
missingStaticBuild: 'View Model Builders must have a static build() method',
missingSatisfies: 'View Model Builders must use "satisfies ViewModelBuilder<...>" for static type enforcement',
invalidBuildSignature: 'build() method must accept View Data and return View Model',
},
},
create(context) {
const filename = context.getFilename();
const isInViewModelBuilders = filename.includes('/lib/builders/view-models/');
if (!isInViewModelBuilders) return {};
let hasStaticBuild = false;
let hasSatisfies = false;
let hasCorrectSignature = false;
return {
// Check class declaration
ClassDeclaration(node) {
const className = node.id?.name;
if (!className || !className.endsWith('ViewModelBuilder')) {
context.report({
node,
messageId: 'notAClass',
});
}
// Check for static build method
const staticBuild = node.body.body.find(member =>
member.type === 'MethodDefinition' &&
member.key.type === 'Identifier' &&
member.key.name === 'build' &&
member.static === true
);
if (staticBuild) {
hasStaticBuild = true;
// Check signature - should have at least one parameter
if (staticBuild.value &&
staticBuild.value.params &&
staticBuild.value.params.length > 0) {
hasCorrectSignature = true;
}
}
},
// Check for satisfies expression
TSSatisfiesExpression(node) {
if (node.typeAnnotation &&
node.typeAnnotation.type === 'TSTypeReference' &&
node.typeAnnotation.typeName.name === 'ViewModelBuilder') {
hasSatisfies = true;
}
},
'Program:exit'() {
if (!hasStaticBuild) {
context.report({
node: context.getSourceCode().ast,
messageId: 'missingStaticBuild',
});
}
if (!hasSatisfies) {
context.report({
node: context.getSourceCode().ast,
messageId: 'missingSatisfies',
});
}
if (hasStaticBuild && !hasCorrectSignature) {
context.report({
node: context.getSourceCode().ast,
messageId: 'invalidBuildSignature',
});
}
},
};
},
};

View File

@@ -1,96 +0,0 @@
/**
* ESLint rule to enforce View Model Builder contract implementation
*
* View Model Builders in lib/builders/view-models/ must:
* 1. Be classes named *ViewModelBuilder
* 2. Implement the ViewModelBuilder<TInput, TOutput> interface
* 3. Have a static build() method
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Enforce View Model Builder contract implementation',
category: 'Builders',
recommended: true,
},
fixable: null,
schema: [],
messages: {
notAClass: 'View Model Builders must be classes named *ViewModelBuilder',
missingImplements: 'View Model Builders must implement ViewModelBuilder<TInput, TOutput> interface',
missingBuildMethod: 'View Model Builders must have a static build() method',
},
},
create(context) {
const filename = context.getFilename();
const isInViewModelBuilders = filename.includes('/lib/builders/view-models/');
if (!isInViewModelBuilders) return {};
let hasImplements = false;
let hasBuildMethod = false;
return {
// Check class declaration
ClassDeclaration(node) {
const className = node.id?.name;
if (!className || !className.endsWith('ViewModelBuilder')) {
context.report({
node,
messageId: 'notAClass',
});
}
// Check if class implements ViewModelBuilder interface
if (node.implements && node.implements.length > 0) {
for (const impl of node.implements) {
// Handle GenericTypeAnnotation for ViewModelBuilder<TInput, TOutput>
if (impl.expression.type === 'TSInstantiationExpression') {
const expr = impl.expression.expression;
if (expr.type === 'Identifier' && expr.name === 'ViewModelBuilder') {
hasImplements = true;
}
} else if (impl.expression.type === 'Identifier') {
// Handle simple ViewModelBuilder (without generics)
if (impl.expression.name === 'ViewModelBuilder') {
hasImplements = true;
}
}
}
}
// Check for static build method
const buildMethod = node.body.body.find(member =>
member.type === 'MethodDefinition' &&
member.key.type === 'Identifier' &&
member.key.name === 'build' &&
member.static === true
);
if (buildMethod) {
hasBuildMethod = true;
}
},
'Program:exit'() {
if (!hasImplements) {
context.report({
node: context.getSourceCode().ast,
messageId: 'missingImplements',
});
}
if (!hasBuildMethod) {
context.report({
node: context.getSourceCode().ast,
messageId: 'missingBuildMethod',
});
}
},
};
},
};

View File

@@ -5,7 +5,7 @@
* 1. NOT contain the word "DTO" (DTOs are for API/Services)
* 2. NOT define ViewData interfaces (ViewData must be in lib/view-data/)
* 3. NOT import from DTO paths (DTOs belong to lib/types/generated/)
* 4. ONLY import from allowed paths: lib/contracts/, lib/view-models/, lib/view-data/, lib/display-objects/
* 4. ONLY import from allowed paths: lib/contracts/, lib/view-models/, lib/view-data/, lib/formatters/
*/
module.exports = {
@@ -22,7 +22,7 @@ module.exports = {
noDtoInViewModel: 'ViewModels must not use the word "DTO". DTOs belong to the API/Service layer. Use plain logic-rich properties or ViewData types.',
noDtoImport: 'ViewModels must not import from DTO paths. DTOs belong to lib/types/generated/. Import from lib/view-data/ or use plain properties instead.',
noViewDataDefinition: 'ViewData must not be defined within ViewModel files. Import them from lib/view-data/ instead.',
strictImport: 'ViewModels can only import from lib/contracts/, lib/view-models/, lib/view-data/, or lib/display-objects/. External imports are allowed. Found: {{importPath}}',
strictImport: 'ViewModels can only import from lib/contracts/, lib/view-models/, lib/view-data/, or lib/formatters/. External imports are allowed. Found: {{importPath}}',
},
},
@@ -70,7 +70,7 @@ module.exports = {
'@/lib/contracts/',
'@/lib/view-models/',
'@/lib/view-data/',
'@/lib/display-objects/',
'@/lib/formatters/',
];
const isAllowed = allowedPaths.some(path => importPath.startsWith(path));
@@ -83,12 +83,12 @@ module.exports = {
importPath.includes('/lib/contracts/') ||
importPath.includes('/lib/view-models/') ||
importPath.includes('/lib/view-data/') ||
importPath.includes('/lib/display-objects/') ||
importPath.includes('/lib/formatters/') ||
// Also check for patterns like ../contracts/...
importPath.includes('contracts') ||
importPath.includes('view-models') ||
importPath.includes('view-data') ||
importPath.includes('display-objects') ||
importPath.includes('formatters') ||
// Allow relative imports to view models (e.g., ./InvoiceViewModel, ../ViewModelName)
// This matches patterns like ./ViewModelName or ../ViewModelName
/^\.\/[A-Z][a-zA-Z0-9]*ViewModel$/.test(importPath) ||

View File

@@ -1,32 +0,0 @@
/**
* ViewModel Builder Contract
*
* Purpose: Transform ViewData into ViewModels for client-side state management
*
* Rules:
* - Deterministic and side-effect free
* - No HTTP/API calls
* - Input: ViewData (JSON-serializable template-ready data)
* - Output: ViewModel (client-only class)
* - Must be in lib/builders/view-models/
* - Must be named *ViewModelBuilder
* - Must have 'use client' directive
* - Must implement static build() method
* - Must use 'satisfies' for static type enforcement
*/
import { ViewData } from '../view-data/ViewData';
import { ViewModel } from '../view-models/ViewModel';
/**
* ViewModel Builder Contract (Static)
*
* Usage:
* export class MyViewModelBuilder {
* static build(viewData: MyViewData): MyViewModel { ... }
* }
* MyViewModelBuilder satisfies ViewModelBuilder<MyViewData, MyViewModel>;
*/
export interface ViewModelBuilder<TViewData extends ViewData, TViewModel extends ViewModel> {
build(viewData: TViewData): TViewModel;
}

View File

@@ -1,22 +1,40 @@
/**
* Formatter contract
*
*
* Deterministic, reusable, UI-only formatting/mapping logic.
*
* Based on DISPLAY_OBJECTS.md:
* - Class-based
* - Immutable
*
* Based on FORMATTERS.md:
* - Stateless (Formatters) or Immutable (Display Objects)
* - Deterministic
* - Side-effect free
* - No Intl.* or toLocale*
* - No business rules
*
* Uncle Bob says: "Data structures should not have behavior."
* Formatters ensure ViewData remains a dumb container of primitives.
*/
export interface Formatter {
/**
* Format or map the display object
*
* @returns Primitive values only (strings, numbers, booleans)
* Format or map the input to a primitive value
*
* @returns Primitive values only (strings, numbers, booleans, null)
*/
format(): unknown;
format(): string | number | boolean | null;
}
/**
* Rich Display Object contract (Client-only)
*
* Used by ViewModels to provide a rich API to the UI.
*/
export interface DisplayObject {
/**
* Primary primitive output
*/
format(): string | number | boolean | null;
/**
* Multiple primitive variants
*/
variants?(): Record<string, string | number | boolean | null>;
}

View File

@@ -1,16 +1,14 @@
import { JsonValue } from "../types/primitives";
/**
* Base interface for ViewData objects
*
* All ViewData must be JSON-serializable for SSR.
* This type ensures no class instances or functions are included.
*
* Note: We use 'any' here to allow complex DTO structures, but the
* architectural rule is that these must be plain JSON objects.
* Uncle Bob says: "Data structures should not have behavior."
* ViewData is a dumb container for primitives and nested JSON only.
*/
export interface ViewData {
[key: string]: JsonValue;
[key: string]: any;
}
/**
* Helper type to ensure a type is ViewData-compatible

View File

@@ -5,7 +5,6 @@ import { ViewData } from '@/lib/contracts/view-data/ViewData';
*
* ViewData for category icon media rendering.
*/
import { ViewData } from "../contracts/view-data/ViewData";
export interface CategoryIconViewData extends ViewData {
buffer: string; // base64 encoded