1097 lines
37 KiB
TypeScript
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, '/');
|
|
}
|
|
} |