Files
gridpilot.gg/apps/website/tests/guardrails/ArchitectureGuardrails.ts
2026-01-12 01:01:49 +01:00

1097 lines
37 KiB
TypeScript

import * as fs from 'fs';
import * as path from 'path';
import { GuardrailViolation } from './GuardrailViolation';
import { ALLOWED_VIOLATIONS } from './allowed-violations';
/**
* Architecture guardrail scanner
*
* Scans the website codebase for architectural violations
* and compares them against an allowlist.
*/
export class ArchitectureGuardrails {
private violations: GuardrailViolation[] = [];
/**
* Scan all files and detect violations
*/
scan(): GuardrailViolation[] {
this.violations = [];
// Check for forbidden hooks directory
this.checkForbiddenHooksDirectory();
// Scan specific directories
this.scanDirectory('apps/website/app', this.scanAppDirectory.bind(this));
this.scanDirectory('apps/website/lib/page-queries', this.scanPageQueryDirectory.bind(this));
this.scanDirectory('apps/website/templates', this.scanTemplateDirectory.bind(this));
this.scanDirectory('apps/website/lib/services', this.scanServiceDirectory.bind(this));
this.scanDirectory('apps/website/lib/view-models', this.scanViewModelDirectory.bind(this));
this.scanDirectory('apps/website/lib/presenters', this.scanPresenterDirectory.bind(this));
this.scanDirectory('apps/website/lib/display-objects', this.scanDisplayObjectDirectory.bind(this));
this.scanDirectory('apps/website/components', this.scanComponentDirectory.bind(this));
this.scanDirectory('apps/website/lib/utilities', this.scanUtilityDirectory.bind(this));
this.scanDirectory('apps/website/lib/api', this.scanApiDirectory.bind(this));
this.scanDirectory('apps/website/lib/di', this.scanDiDirectory.bind(this));
this.scanDirectory('apps/website/hooks', this.scanHooksDirectory.bind(this));
return this.violations;
}
/**
* Get violations after filtering by allowlist
*/
getFilteredViolations(): GuardrailViolation[] {
const allViolations = this.scan();
return allViolations.filter(violation => {
const allowed = ALLOWED_VIOLATIONS[violation.ruleName] || [];
return !allowed.includes(violation.filePath);
});
}
/**
* Check if allowlist has stale entries
*/
findStaleAllowlistEntries(): string[] {
const allViolations = this.scan();
const staleEntries: string[] = [];
for (const [ruleName, allowedFiles] of Object.entries(ALLOWED_VIOLATIONS)) {
for (const allowedFile of allowedFiles) {
const stillExists = allViolations.some(v =>
v.ruleName === ruleName && v.filePath === allowedFile
);
if (!stillExists) {
staleEntries.push(`${ruleName}: ${allowedFile}`);
}
}
}
return staleEntries;
}
// ============================================================================
// DIRECTORY SCANNERS
// ============================================================================
private scanDirectory(dirPath: string, scanner: (filePath: string, content: string) => void): void {
if (!fs.existsSync(dirPath)) return;
const readDirRecursive = (currentPath: string): void => {
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
if (entry.isDirectory()) {
readDirRecursive(fullPath);
} else if (entry.isFile()) {
const relativePath = this.normalizePath(fullPath);
if (!relativePath.endsWith('.ts') && !relativePath.endsWith('.tsx')) continue;
const content = fs.readFileSync(fullPath, 'utf-8');
scanner(relativePath, content);
}
}
};
readDirRecursive(dirPath);
}
private scanAppDirectory(filePath: string, content: string): void {
// Rule 1: RSC boundary guardrails for page.tsx files
if (filePath.match(/app\/.*\/page\.tsx$/)) {
this.checkContainerManager(filePath, content);
this.checkPageDataFetcherFetch(filePath, content);
this.checkViewModelsImport(filePath, content);
this.checkPresenterImport(filePath, content);
this.checkIntlUsage(filePath, content);
this.checkSortingFiltering(filePath, content);
this.checkDisplayObjectsImport(filePath, content);
this.checkServerSafeServicesImport(filePath, content);
this.checkDIImport(filePath, content);
this.checkLocalHelpers(filePath, content);
this.checkObjectConstruction(filePath, content);
this.checkContainerManagerCalls(filePath, content);
this.checkNoTemplatesInApp(filePath, content);
}
// Rule 5: Forbid client-side write fetch
if (filePath.match(/app\/.*\/.*\.tsx$/)) {
this.checkClientWriteFetch(filePath, content);
}
// Rule 7: Server actions guardrails
if (filePath.match(/app\/.*\/actions\.ts$/)) {
this.checkServerActions(filePath, content);
}
// Rule 8: Forbid 'as any' usage in all files
this.checkAsAnyUsage(filePath, content);
// Rule 9: Model taxonomy - variable naming
this.checkVariableNaming(filePath, content);
// Rule 10: Generated DTO isolation
this.checkGeneratedDTOImport(filePath, content);
// Rule 11: Filename rules for app directory
this.checkAppFilenameRules(filePath, content);
}
private scanPageQueryDirectory(filePath: string, content: string): void {
// Rule 1: Forbid ContainerManager in page queries
this.checkContainerManager(filePath, content);
// Rule 2: Forbid PageDataFetcher.fetch() in page queries
this.checkPageDataFetcherFetch(filePath, content);
// Rule 3: Forbid view-models imports in page queries
this.checkViewModelsImport(filePath, content);
// Rule 4: Forbid Intl usage in page queries
this.checkIntlUsage(filePath, content);
// Rule 4: Page Query guardrails - additional checks
this.checkPresenterImport(filePath, content);
this.checkDisplayObjectsImport(filePath, content);
this.checkDIImport(filePath, content);
this.checkSortingFiltering(filePath, content);
this.checkNullReturns(filePath, content);
// Rule 8: Forbid 'as any' usage
this.checkAsAnyUsage(filePath, content);
// Rule 9: Model taxonomy - variable naming
this.checkVariableNaming(filePath, content);
// Rule 10: Generated DTO isolation
this.checkGeneratedDTOImport(filePath, content);
// Rule 11: Filename rules for page queries
this.checkPageQueryFilenameRules(filePath, content);
}
private scanTemplateDirectory(filePath: string, content: string): void {
// Rule 2: Template purity guardrails
this.checkTemplateImports(filePath, content);
this.checkPresenterImport(filePath, content);
this.checkIntlUsage(filePath, content);
this.checkTemplateStateHooks(filePath, content);
this.checkTemplateComputations(filePath, content);
this.checkTemplateImportsFromRestricted(filePath, content);
// Rule 8: Forbid 'as any' usage
this.checkAsAnyUsage(filePath, content);
// Rule 9: Model taxonomy - variable naming and type rules
this.checkVariableNaming(filePath, content);
this.checkTemplateViewDataSignature(filePath, content);
this.checkTemplateExports(filePath, content);
// Rule 10: Generated DTO isolation
this.checkGeneratedDTOImport(filePath, content);
// Rule 11: Filename rules for templates
this.checkTemplateFilenameRules(filePath, content);
}
private scanServiceDirectory(filePath: string, content: string): void {
// Rule 3: Services guardrails
this.checkViewModelsImport(filePath, content);
this.checkDisplayObjectsImport(filePath, content);
this.checkServiceStatefulness(filePath, content);
this.checkServiceBlockers(filePath, content);
// Rule 5: Services should be server-safe
this.checkServiceNaming(filePath, content);
// Rule 8: Forbid 'as any' usage
this.checkAsAnyUsage(filePath, content);
// Rule 9: Model taxonomy - variable naming
this.checkVariableNaming(filePath, content);
// Rule 10: Generated DTO isolation
this.checkGeneratedDTOImport(filePath, content);
}
private scanViewModelDirectory(filePath: string, content: string): void {
// Rule 5: Forbid Intl usage in view-models
this.checkIntlUsage(filePath, content);
// Rule 6: Client-only guardrails
this.checkUseClientDirective(filePath, content);
this.checkViewModelPageQueryImports(filePath, content);
// Rule 8: Forbid 'as any' usage
this.checkAsAnyUsage(filePath, content);
// Rule 9: Model taxonomy - variable naming
this.checkVariableNaming(filePath, content);
// Rule 10: Generated DTO isolation
this.checkGeneratedDTOImport(filePath, content);
}
private scanPresenterDirectory(filePath: string, content: string): void {
// Rule 5: Forbid Intl usage in presenters
this.checkIntlUsage(filePath, content);
// Rule 6: Client-only guardrails - presenters should not use HTTP
this.checkHttpCalls(filePath, content);
// Rule 8: Forbid 'as any' usage
this.checkAsAnyUsage(filePath, content);
// Rule 9: Model taxonomy - variable naming
this.checkVariableNaming(filePath, content);
// Rule 10: Generated DTO isolation
this.checkGeneratedDTOImport(filePath, content);
}
private scanDisplayObjectDirectory(filePath: string, content: string): void {
// Rule 3: Display Object guardrails
this.checkIntlUsage(filePath, content);
this.checkDisplayObjectImports(filePath, content);
this.checkDisplayObjectExports(filePath, content);
// Rule 8: Forbid 'as any' usage
this.checkAsAnyUsage(filePath, content);
// Rule 9: Model taxonomy - variable naming
this.checkVariableNaming(filePath, content);
// Rule 10: Generated DTO isolation
this.checkGeneratedDTOImport(filePath, content);
}
private scanComponentDirectory(filePath: string, content: string): void {
// Rule 5: Forbid Intl usage in components
this.checkIntlUsage(filePath, content);
// Rule 7: Client-side write fetch
this.checkClientWriteFetch(filePath, content);
// Rule 8: Forbid 'as any' usage
this.checkAsAnyUsage(filePath, content);
// Rule 9: Model taxonomy - variable naming
this.checkVariableNaming(filePath, content);
// Rule 10: Generated DTO isolation
this.checkGeneratedDTOImport(filePath, content);
}
private scanUtilityDirectory(filePath: string, content: string): void {
// Rule 5: Forbid Intl usage in utilities
this.checkIntlUsage(filePath, content);
// Rule 8: Forbid 'as any' usage
this.checkAsAnyUsage(filePath, content);
// Rule 9: Model taxonomy - variable naming
this.checkVariableNaming(filePath, content);
// Rule 10: Generated DTO isolation
this.checkGeneratedDTOImport(filePath, content);
}
private scanApiDirectory(filePath: string, content: string): void {
// Rule 8: Forbid 'as any' usage
this.checkAsAnyUsage(filePath, content);
// Rule 9: Model taxonomy - variable naming
this.checkVariableNaming(filePath, content);
}
private scanDiDirectory(filePath: string, content: string): void {
// Rule 8: Forbid 'as any' usage
this.checkAsAnyUsage(filePath, content);
// Rule 9: Model taxonomy - variable naming
this.checkVariableNaming(filePath, content);
}
private scanHooksDirectory(filePath: string, content: string): void {
// Rule 8: Forbid 'as any' usage
this.checkAsAnyUsage(filePath, content);
// Rule 9: Model taxonomy - variable naming
this.checkVariableNaming(filePath, content);
// Rule 10: Generated DTO isolation
this.checkGeneratedDTOImport(filePath, content);
}
// ============================================================================
// VIOLATION CHECKS - RSC BOUNDARY GUARDRAILS
// ============================================================================
private checkContainerManager(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
if (line.includes('ContainerManager') && !line.trim().startsWith('//')) {
this.addViolation(
'no-container-manager-in-server',
filePath,
index + 1,
'ContainerManager usage forbidden in server code'
);
}
});
}
private checkPageDataFetcherFetch(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
if (line.includes('PageDataFetcher.fetch(') && !line.trim().startsWith('//')) {
this.addViolation(
'no-page-data-fetcher-fetch-in-server',
filePath,
index + 1,
'PageDataFetcher.fetch() forbidden in server code'
);
}
});
}
private checkViewModelsImport(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
// Check for both single and double quotes
if ((line.includes("from '@/lib/view-models/") ||
line.includes('from "@/lib/view-models/') ||
line.includes("from '@/lib/presenters/") ||
line.includes('from "@/lib/presenters/')) && !line.trim().startsWith('//')) {
this.addViolation(
'no-view-models-in-server',
filePath,
index + 1,
'ViewModels or Presenters import forbidden in server code'
);
}
});
}
private checkPresenterImport(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
if ((line.includes("from '@/lib/presenters/") ||
line.includes('from "@/lib/presenters/')) && !line.trim().startsWith('//')) {
this.addViolation(
'no-presenters-in-server',
filePath,
index + 1,
'Presenter import forbidden in server code'
);
}
});
}
private checkIntlUsage(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
if ((line.includes('Intl.') || line.includes('.toLocale')) && !line.trim().startsWith('//')) {
this.addViolation(
'no-intl-in-presentation',
filePath,
index + 1,
'Intl.* or toLocale* usage forbidden in presentation paths'
);
}
});
}
private checkSortingFiltering(filePath: string, content: string): void {
const lines = content.split('\n');
const patterns = [
/\.sort\(/,
/\.filter\(/,
/\.reduce\(/,
/\bsort\(/,
/\bfilter\(/,
/\breduce\(/
];
lines.forEach((line, index) => {
// Skip comments and trivial null checks
if (line.trim().startsWith('//') || line.includes('null check')) return;
for (const pattern of patterns) {
if (pattern.test(line)) {
this.addViolation(
'no-sorting-filtering-in-server',
filePath,
index + 1,
'Sorting/filtering/reduce operations forbidden in server code'
);
break;
}
}
});
}
private checkDisplayObjectsImport(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
if ((line.includes("from '@/lib/display-objects/") ||
line.includes('from "@/lib/display-objects/')) && !line.trim().startsWith('//')) {
this.addViolation(
'no-display-objects-in-server',
filePath,
index + 1,
'DisplayObjects import forbidden in server code'
);
}
});
}
private checkServerSafeServicesImport(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
if ((line.includes("from '@/lib/services/") ||
line.includes('from "@/lib/services/')) && !line.trim().startsWith('//')) {
// Check if it's explicitly marked as server-safe
if (!content.includes('// @server-safe') && !content.includes('/* @server-safe */')) {
this.addViolation(
'no-unsafe-services-in-server',
filePath,
index + 1,
'Services import must be explicitly marked as server-safe'
);
}
}
});
}
private checkDIImport(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
if ((line.includes("from '@/lib/di/") ||
line.includes('from "@/lib/di/')) && !line.trim().startsWith('//')) {
this.addViolation(
'no-di-in-server',
filePath,
index + 1,
'DI import forbidden in server code'
);
}
});
}
private checkLocalHelpers(filePath: string, content: string): void {
// Look for function definitions that are not assert*/invariant* guards
const lines = content.split('\n');
let inComment = false;
lines.forEach((line, index) => {
const trimmed = line.trim();
// Track comment blocks
if (trimmed.startsWith('/*')) inComment = true;
if (trimmed.endsWith('*/')) inComment = false;
if (trimmed.startsWith('//') || inComment) return;
// Check for function definitions
if (/^(async\s+)?function\s+\w+/.test(trimmed) ||
/^(const|let|var)\s+\w+\s*=\s*(async\s+)?function/.test(trimmed) ||
/^(const|let|var)\s+\w+\s*=\s*\([^)]*\)\s*=>/.test(trimmed)) {
// Allow assert* and invariant* functions
if (!trimmed.match(/^(const|let|var)\s+(assert|invariant)\w+/) &&
!trimmed.match(/^function\s+(assert|invariant)\w+/)) {
this.addViolation(
'no-local-helpers-in-server',
filePath,
index + 1,
'Local helper functions forbidden (only assert*/invariant* allowed)'
);
}
}
});
}
private checkObjectConstruction(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
// Look for 'new SomeClass()' patterns
if (/new\s+[A-Z]\w+\(/.test(line) && !line.trim().startsWith('//')) {
this.addViolation(
'no-object-construction-in-server',
filePath,
index + 1,
'Object construction with new forbidden (use PageQueries)'
);
}
});
}
private checkContainerManagerCalls(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
if ((line.includes('ContainerManager.getInstance()') ||
line.includes('ContainerManager.getContainer()')) && !line.trim().startsWith('//')) {
this.addViolation(
'no-container-manager-calls-in-server',
filePath,
index + 1,
'ContainerManager calls forbidden in server code'
);
}
});
}
private checkNoTemplatesInApp(filePath: string, content: string): void {
if (filePath.includes('/app/') && filePath.includes('Template.tsx')) {
this.addViolation(
'no-templates-in-app',
filePath,
1,
'*Template.tsx files forbidden under app/'
);
}
}
// ============================================================================
// VIOLATION CHECKS - TEMPLATE PURITY GUARDRAILS
// ============================================================================
private checkTemplateImports(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
if (line.includes("from '@/lib/view-models/") ||
line.includes("from '@/lib/presenters/") ||
line.includes("from '@/lib/display-objects/")) {
if (!line.trim().startsWith('//')) {
this.addViolation(
'no-view-models-in-templates',
filePath,
index + 1,
'ViewModels or DisplayObjects import forbidden in templates'
);
}
}
});
}
private checkTemplateStateHooks(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
if ((line.includes('useMemo') ||
line.includes('useEffect') ||
line.includes('useState') ||
line.includes('useReducer')) && !line.trim().startsWith('//')) {
this.addViolation(
'no-state-hooks-in-templates',
filePath,
index + 1,
'State hooks forbidden in templates (use *PageClient.tsx)'
);
}
});
}
private checkTemplateComputations(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
if ((line.includes('.filter(') ||
line.includes('.sort(') ||
line.includes('.reduce(')) && !line.trim().startsWith('//')) {
this.addViolation(
'no-computations-in-templates',
filePath,
index + 1,
'Derived computations forbidden in templates'
);
}
});
}
private checkTemplateImportsFromRestricted(filePath: string, content: string): void {
const lines = content.split('\n');
const restrictedPaths = [
"from '@/lib/page-queries/",
'from "@/lib/page-queries/',
"from '@/lib/services/",
'from "@/lib/services/',
"from '@/lib/api/",
'from "@/lib/api/',
"from '@/lib/di/",
'from "@/lib/di/',
"from '@/lib/contracts/",
'from "@/lib/contracts/'
];
lines.forEach((line, index) => {
for (const path of restrictedPaths) {
if (line.includes(path) && !line.trim().startsWith('//')) {
this.addViolation(
'no-restricted-imports-in-templates',
filePath,
index + 1,
'Templates cannot import from page-queries, services, api, di, or contracts'
);
break;
}
}
});
}
private checkTemplateViewDataSignature(filePath: string, content: string): void {
// Look for component function declarations
const componentRegex = /export\s+(default\s+)?function\s+(\w+)\s*\(([^)]*)\)/g;
let match;
while ((match = componentRegex.exec(content)) !== null) {
const params = match[3]?.trim();
if (params && !params.includes('ViewData') && !params.includes('viewData')) {
this.addViolation(
'no-invalid-template-signature',
filePath,
1,
'Template component must accept *ViewData type as first parameter'
);
}
}
}
private checkTemplateExports(filePath: string, content: string): void {
// Look for exported functions that are not the main component
const lines = content.split('\n');
let inComment = false;
lines.forEach((line, index) => {
const trimmed = line.trim();
if (trimmed.startsWith('/*')) inComment = true;
if (trimmed.endsWith('*/')) inComment = false;
if (trimmed.startsWith('//') || inComment) return;
// Check for export function/const that's not the default component
if ((trimmed.startsWith('export function') ||
trimmed.startsWith('export const') ||
trimmed.startsWith('export default function')) &&
!trimmed.includes('export default function')) {
this.addViolation(
'no-template-helper-exports',
filePath,
index + 1,
'Templates must not export helper functions'
);
}
});
}
private checkTemplateFilenameRules(filePath: string, content: string): void {
if (filePath.includes('/templates/') && !filePath.endsWith('Template.tsx')) {
this.addViolation(
'invalid-template-filename',
filePath,
1,
'Template files must end with Template.tsx'
);
}
}
// ============================================================================
// VIOLATION CHECKS - DISPLAY OBJECT GUARDRAILS
// ============================================================================
private checkDisplayObjectImports(filePath: string, content: string): void {
const lines = content.split('\n');
const forbiddenPaths = [
"from '@/lib/api/",
'from "@/lib/api/',
"from '@/lib/services/",
'from "@/lib/services/',
"from '@/lib/page-queries/",
'from "@/lib/page-queries/',
"from '@/lib/view-models/",
'from "@/lib/view-models/',
"from '@/lib/presenters/",
'from "@/lib/presenters/'
];
lines.forEach((line, index) => {
for (const path of forbiddenPaths) {
if (line.includes(path) && !line.trim().startsWith('//')) {
this.addViolation(
'no-io-in-display-objects',
filePath,
index + 1,
'DisplayObjects cannot import from api, services, page-queries, or view-models'
);
break;
}
}
});
}
private checkDisplayObjectExports(filePath: string, content: string): void {
// Check that Display Objects only export class members
const lines = content.split('\n');
lines.forEach((line, index) => {
const trimmed = line.trim();
if ((trimmed.startsWith('export function') ||
trimmed.startsWith('export const') ||
trimmed.startsWith('export default function')) &&
!trimmed.includes('export default class') &&
!trimmed.includes('export class')) {
this.addViolation(
'no-non-class-display-exports',
filePath,
index + 1,
'Display Objects must be class-based and export only classes'
);
}
});
}
// ============================================================================
// VIOLATION CHECKS - PAGE QUERY GUARDRAILS
// ============================================================================
private checkNullReturns(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
if (line.includes('return null') && !line.trim().startsWith('//')) {
this.addViolation(
'no-null-returns-in-page-queries',
filePath,
index + 1,
'PageQueries must return PageQueryResult union, not null'
);
}
});
}
private checkPageQueryFilenameRules(filePath: string, content: string): void {
if (filePath.includes('/page-queries/') && !filePath.endsWith('PageQuery.ts')) {
this.addViolation(
'invalid-page-query-filename',
filePath,
1,
'PageQuery files must end with PageQuery.ts'
);
}
}
// ============================================================================
// VIOLATION CHECKS - SERVICES GUARDRAILS
// ============================================================================
private checkServiceStatefulness(filePath: string, content: string): void {
// Look for state storage on 'this' beyond injected dependencies
const lines = content.split('\n');
lines.forEach((line, index) => {
if (line.includes('this.') &&
!line.includes('this.') && // Basic check for this.property
!line.trim().startsWith('//')) {
// This is a simplified check - in practice would need more sophisticated analysis
if (line.match(/this\.\w+\s*=\s*(?!inject|constructor)/)) {
this.addViolation(
'no-service-state',
filePath,
index + 1,
'Services must be stateless (no state on this beyond dependencies)'
);
}
}
});
}
private checkServiceBlockers(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
if (line.includes('blocker') && !line.trim().startsWith('//')) {
this.addViolation(
'no-blockers-in-services',
filePath,
index + 1,
'Services cannot use blockers (client-only UX helpers)'
);
}
});
}
private checkServiceNaming(filePath: string, content: string): void {
// Check for proper variable naming in service methods
const lines = content.split('\n');
lines.forEach((line, index) => {
// Look for 'dto' variable name (forbidden)
if (line.includes(' dto ') || line.includes(' dto=') || line.includes('(dto)')) {
this.addViolation(
'no-dto-variable-name',
filePath,
index + 1,
'Variable name "dto" forbidden - use apiDto, pageDto, viewData, or commandDto'
);
}
});
}
// ============================================================================
// VIOLATION CHECKS - CLIENT-ONLY GUARDRAILS
// ============================================================================
private checkUseClientDirective(filePath: string, content: string): void {
if (filePath.includes('/lib/view-models/') || filePath.includes('/lib/presenters/')) {
const hasUseClient = content.includes("'use client'") || content.includes('"use client"');
if (!hasUseClient) {
this.addViolation(
'no-use-client-directive',
filePath,
1,
'ViewModels must have \'use client\' directive at top-level'
);
}
}
}
private checkViewModelPageQueryImports(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
if ((line.includes("from '@/lib/page-queries/") ||
line.includes('from "@/lib/page-queries/') ||
line.includes("from '@/app/") ||
line.includes('from "@/app/')) && !line.trim().startsWith('//')) {
this.addViolation(
'no-viewmodel-imports-from-server',
filePath,
index + 1,
'ViewModels cannot import from page-queries or app'
);
}
});
}
private checkHttpCalls(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
if ((line.includes('fetch(') ||
line.includes('axios.') ||
line.includes('apiClient.') ||
line.includes('http.')) && !line.trim().startsWith('//')) {
this.addViolation(
'no-http-in-presenters',
filePath,
index + 1,
'Presenters/ViewModels cannot use HTTP calls'
);
}
});
}
// ============================================================================
// VIOLATION CHECKS - WRITE BOUNDARY GUARDRAILS
// ============================================================================
private checkClientWriteFetch(filePath: string, content: string): void {
// Check for 'use client' directive
const hasUseClient = content.includes("'use client'") || content.includes('"use client"');
if (!hasUseClient) return;
// Check for fetch with write methods
const writeMethods = ['POST', 'PUT', 'PATCH', 'DELETE'];
const lines = content.split('\n');
lines.forEach((line, index) => {
writeMethods.forEach(method => {
if (line.includes(`method: '${method}'`) || line.includes(`method: "${method}"`)) {
this.addViolation(
'no-client-write-fetch',
filePath,
index + 1,
`Client-side fetch with ${method} method forbidden`
);
}
});
});
}
private checkServerActions(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
// Check for view-models or templates imports
if ((line.includes("from '@/lib/view-models/") ||
line.includes('from "@/lib/view-models/') ||
line.includes("from '@/lib/presenters/") ||
line.includes('from "@/lib/presenters/') ||
line.includes("from '@/templates/") ||
line.includes('from "@/templates/')) && !line.trim().startsWith('//')) {
this.addViolation(
'no-server-action-imports-from-client',
filePath,
index + 1,
'Server actions cannot import ViewModels or Templates'
);
}
// Check for ViewModel returns
if (line.includes('return') &&
(line.includes('ViewModel') || line.includes('viewModel')) &&
!line.trim().startsWith('//')) {
this.addViolation(
'no-server-action-viewmodel-returns',
filePath,
index + 1,
'Server actions must return primitives/redirect/revalidate, not ViewModels'
);
}
});
}
// ============================================================================
// VIOLATION CHECKS - MODEL TAXONOMY GUARDRAILS
// ============================================================================
private checkVariableNaming(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
// Check for forbidden variable name 'dto'
if (/\bdto\b/.test(line) && !line.trim().startsWith('//')) {
this.addViolation(
'no-dto-variable-name',
filePath,
index + 1,
'Variable name "dto" forbidden - use apiDto, pageDto, viewData, or commandDto'
);
}
});
}
private checkGeneratedDTOImport(filePath: string, content: string): void {
const lines = content.split('\n');
const forbiddenPaths = [
"from '@/lib/types/generated/",
'from "@/lib/types/generated/'
];
// Check if file is in forbidden locations
const isForbiddenLocation =
filePath.includes('/templates/') ||
filePath.includes('/components/') ||
filePath.includes('/hooks/') ||
filePath.includes('/lib/hooks/');
if (isForbiddenLocation) {
lines.forEach((line, index) => {
for (const path of forbiddenPaths) {
if (line.includes(path) && !line.trim().startsWith('//')) {
this.addViolation(
'no-generated-dto-in-ui',
filePath,
index + 1,
'Generated DTOs forbidden in templates, components, or hooks'
);
break;
}
}
});
}
// Check templates for any types imports
if (filePath.includes('/templates/')) {
lines.forEach((line, index) => {
if ((line.includes("from '@/lib/types/") ||
line.includes('from "@/lib/types/')) && !line.trim().startsWith('//')) {
this.addViolation(
'no-types-in-templates',
filePath,
index + 1,
'Templates cannot import from lib/types'
);
}
});
}
}
// ============================================================================
// VIOLATION CHECKS - FILENAME RULES
// ============================================================================
private checkAppFilenameRules(filePath: string, content: string): void {
if (!filePath.includes('/app/')) return;
// Allowed extensions under app/
const allowedFiles = [
'page.tsx',
'layout.tsx',
'loading.tsx',
'error.tsx',
'not-found.tsx',
'actions.ts'
];
const isAllowed = allowedFiles.some(allowed => filePath.endsWith(allowed));
if (!isAllowed) {
// Check for forbidden patterns
if (filePath.includes('Template.tsx') ||
filePath.includes('ViewModel.ts') ||
filePath.includes('Presenter.ts')) {
this.addViolation(
'invalid-app-filename',
filePath,
1,
'app/ directory can only contain page.tsx, layout.tsx, loading.tsx, error.tsx, not-found.tsx, or actions.ts'
);
}
}
}
// ============================================================================
// VIOLATION CHECKS - FORBIDDEN DIRECTORIES
// ============================================================================
private checkForbiddenHooksDirectory(): void {
const hooksPath = 'apps/website/hooks';
if (fs.existsSync(hooksPath)) {
this.addViolation(
'no-hooks-directory',
hooksPath,
1,
'apps/website/hooks directory forbidden - hooks must be in apps/website/lib/hooks'
);
}
}
// ============================================================================
// VIOLATION CHECKS - GENERAL
// ============================================================================
private checkAsAnyUsage(filePath: string, content: string): void {
const lines = content.split('\n');
lines.forEach((line, index) => {
// Check for 'as any' pattern
if (/\bas any\b/.test(line) && !line.trim().startsWith('//')) {
this.addViolation(
'no-as-any',
filePath,
index + 1,
'Type assertion "as any" is forbidden'
);
}
});
}
// ============================================================================
// HELPERS
// ============================================================================
private addViolation(ruleName: string, filePath: string, lineNumber: number, description: string): void {
this.violations.push(new GuardrailViolation(ruleName, filePath, lineNumber, description));
}
private normalizePath(filePath: string): string {
// Convert absolute path to relative from workspace root
const workspaceRoot = path.resolve('/Users/marcmintel/Projects/gridpilot');
let relative = path.relative(workspaceRoot, filePath);
// Normalize to forward slashes
return relative.replace(/\\/g, '/');
}
}