/** * cli:roslyn-validator — execute.ts * 10 regles SmartStack (SS001-SS010) par analyse regex des fichiers .cs * Ne fait PAS de validation d'input — c'est le role de validate.ts */ import { readFileSync, readdirSync, statSync } from 'node:fs' import { join, basename, relative } from 'node:path' import type { RoslynInput, CheckResult, RoslynReport } from './types' // ─── Utilitaires ─── /** Parcours recursif pour trouver les fichiers .cs */ function findCsFiles(dir: string, pattern?: RegExp): string[] { const results: string[] = [] let entries: string[] try { entries = readdirSync(dir) } catch { return results } for (const entry of entries) { const fullPath = join(dir, entry) let stat try { stat = statSync(fullPath) } catch { continue } if (stat.isDirectory()) { // Skip common non-source directories if (entry === 'bin' || entry === 'obj' || entry === 'node_modules' || entry === '.git') continue results.push(...findCsFiles(fullPath, pattern)) } else if (entry.endsWith('.cs')) { if (!pattern || pattern.test(entry)) { results.push(fullPath) } } } return results } /** Lit un fichier et retourne ses lignes */ function readLines(filePath: string): string[] { return readFileSync(filePath, 'utf-8').split(/\r?\n/) } // ─── Regles SS001-SS010 ─── /** SS001: Herite ControllerBase + [ApiController] */ function checkSS001(files: string[], srcDir: string): CheckResult[] { const results: CheckResult[] = [] if (files.length === 0) { results.push({ code: 'SS001', status: 'skipped', message: 'No controller files found' }) return results } let okCount = 0 for (const file of files) { const content = readFileSync(file, 'utf-8') const relPath = relative(srcDir, file) const hasApiController = /\[ApiController\]/.test(content) const hasControllerBase = /class\s+\w+Controller[^{]*:\s*[^{]*ControllerBase/.test(content) if (hasApiController && hasControllerBase) { okCount++ } else { const missing: string[] = [] if (!hasApiController) missing.push('[ApiController]') if (!hasControllerBase) missing.push('ControllerBase inheritance') results.push({ code: 'SS001', status: 'error', file: relPath, message: `Missing ${missing.join(' and ')}`, }) } } if (okCount > 0 && results.length === 0) { results.push({ code: 'SS001', status: 'ok', count: okCount }) } return results } /** SS002: Injection unique IService (pas DbContext) */ function checkSS002(files: string[], srcDir: string): CheckResult[] { const results: CheckResult[] = [] if (files.length === 0) { results.push({ code: 'SS002', status: 'skipped', message: 'No controller files found' }) return results } let okCount = 0 for (const file of files) { const content = readFileSync(file, 'utf-8') const lines = content.split(/\r?\n/) const relPath = relative(srcDir, file) // Check for forbidden DbContext injection const hasDbContext = /DbContext|ICoreDbContext/.test(content) if (hasDbContext) { // Find line number const lineNum = lines.findIndex(l => /DbContext|ICoreDbContext/.test(l)) results.push({ code: 'SS002', status: 'error', file: relPath, line: lineNum >= 0 ? lineNum + 1 : undefined, message: 'Controller injects DbContext directly — must use IService only', }) continue } // Find constructor and check it has a single IService or ISender parameter const constructorMatch = content.match(/public\s+\w+Controller\s*\(([^)]*)\)/) if (constructorMatch) { const params = constructorMatch[1].trim() if (params.length > 0) { // Split parameters by comma, trimming each const paramList = params.split(',').map(p => p.trim()).filter(p => p.length > 0) // Check that all params are IService or ISender types const nonServiceParams = paramList.filter(p => { return !(/^I\w+Service\s/.test(p) || /^ISender\s/.test(p)) }) if (nonServiceParams.length > 0) { const lineNum = lines.findIndex(l => /public\s+\w+Controller\s*\(/.test(l)) results.push({ code: 'SS002', status: 'warning', file: relPath, line: lineNum >= 0 ? lineNum + 1 : undefined, message: `Constructor has non-service parameter(s): ${nonServiceParams.map(p => p.split(/\s/)[0]).join(', ')}`, }) continue } } } okCount++ } if (okCount > 0 && results.filter(r => r.status === 'error').length === 0) { results.unshift({ code: 'SS002', status: 'ok', count: okCount }) } return results } /** SS003: [NavRoute] present et format module.section */ function checkSS003(files: string[], srcDir: string): CheckResult[] { const results: CheckResult[] = [] if (files.length === 0) { results.push({ code: 'SS003', status: 'skipped', message: 'No controller files found' }) return results } let okCount = 0 for (const file of files) { const content = readFileSync(file, 'utf-8') const lines = content.split(/\r?\n/) const relPath = relative(srcDir, file) const navRouteMatch = content.match(/\[NavRoute\("([^"]*)"\)\]/) if (!navRouteMatch) { results.push({ code: 'SS003', status: 'error', file: relPath, message: 'Missing [NavRoute] attribute', }) continue } const routeValue = navRouteMatch[1] if (!/^\w+\.\w+/.test(routeValue)) { const lineNum = lines.findIndex(l => /\[NavRoute\(/.test(l)) results.push({ code: 'SS003', status: 'error', file: relPath, line: lineNum >= 0 ? lineNum + 1 : undefined, message: `[NavRoute] value "${routeValue}" does not match format module.section`, }) continue } okCount++ } if (okCount > 0 && results.filter(r => r.status === 'error').length === 0) { results.unshift({ code: 'SS003', status: 'ok', count: okCount }) } return results } /** SS004: [RequirePermission] sur chaque endpoint public */ function checkSS004(files: string[], srcDir: string): CheckResult[] { const results: CheckResult[] = [] if (files.length === 0) { results.push({ code: 'SS004', status: 'skipped', message: 'No controller files found' }) return results } let okCount = 0 let hasError = false for (const file of files) { const lines = readLines(file) const relPath = relative(srcDir, file) let fileOk = true for (let i = 0; i < lines.length; i++) { const line = lines[i] // Detect HTTP method attributes const httpMatch = line.match(/\[(Http(?:Get|Post|Put|Delete|Patch))/) if (!httpMatch) continue // Look for [RequirePermission] in the 5 lines before and 5 lines after const windowStart = Math.max(0, i - 5) const windowEnd = Math.min(lines.length - 1, i + 5) let hasPermission = false for (let j = windowStart; j <= windowEnd; j++) { if (/\[RequirePermission/.test(lines[j])) { hasPermission = true break } } if (!hasPermission) { results.push({ code: 'SS004', status: 'error', file: relPath, line: i + 1, message: `Endpoint [${httpMatch[1]}] sans [RequirePermission]`, }) fileOk = false hasError = true } } if (fileOk) okCount++ } if (okCount > 0 && !hasError) { results.unshift({ code: 'SS004', status: 'ok', count: okCount }) } return results } /** SS005: Retourne DTOs, jamais entites */ function checkSS005(files: string[], srcDir: string): CheckResult[] { const results: CheckResult[] = [] if (files.length === 0) { results.push({ code: 'SS005', status: 'skipped', message: 'No controller files found' }) return results } let okCount = 0 let hasError = false for (const file of files) { const lines = readLines(file) const relPath = relative(srcDir, file) let fileOk = true for (let i = 0; i < lines.length; i++) { const line = lines[i] // Detect returning entities directly: Ok(entity), return entity if (/\bOk\(\s*entity\b/.test(line) || /\breturn\s+entity\b/.test(line)) { results.push({ code: 'SS005', status: 'error', file: relPath, line: i + 1, message: 'Returns entity directly instead of DTO', }) fileOk = false hasError = true } } if (fileOk) okCount++ } if (okCount > 0 && !hasError) { results.unshift({ code: 'SS005', status: 'ok', count: okCount }) } return results } /** SS006: CancellationToken dernier parametre des methodes async publiques */ function checkSS006(files: string[], srcDir: string): CheckResult[] { const results: CheckResult[] = [] if (files.length === 0) { results.push({ code: 'SS006', status: 'skipped', message: 'No controller files found' }) return results } let okCount = 0 let hasError = false for (const file of files) { const content = readFileSync(file, 'utf-8') const lines = content.split(/\r?\n/) const relPath = relative(srcDir, file) let fileOk = true // Match public async methods with their full parameter list // We need to handle multi-line signatures, so join and search const joined = lines.join('\n') const methodRegex = /public\s+async\s+Task[^(]*\(([^)]*)\)/g let match while ((match = methodRegex.exec(joined)) !== null) { const paramsStr = match[1].trim() if (paramsStr.length === 0) continue const params = paramsStr.split(',').map(p => p.trim()) const lastParam = params[params.length - 1] if (!/CancellationToken/.test(lastParam)) { // Find line number of this match const beforeMatch = joined.substring(0, match.index) const lineNum = beforeMatch.split('\n').length results.push({ code: 'SS006', status: 'error', file: relPath, line: lineNum, message: 'Async public method missing CancellationToken as last parameter', }) fileOk = false hasError = true } } if (fileOk) okCount++ } if (okCount > 0 && !hasError) { results.unshift({ code: 'SS006', status: 'ok', count: okCount }) } return results } /** SS007: NavRoute unique dans le projet */ function checkSS007(files: string[], srcDir: string): CheckResult[] { const results: CheckResult[] = [] if (files.length === 0) { results.push({ code: 'SS007', status: 'skipped', message: 'No controller files found' }) return results } // Collect all NavRoute values across all controllers const routeMap = new Map() for (const file of files) { const content = readFileSync(file, 'utf-8') const relPath = relative(srcDir, file) const navRouteMatch = content.match(/\[NavRoute\("([^"]*)"\)\]/) if (navRouteMatch) { const routeValue = navRouteMatch[1] const existing = routeMap.get(routeValue) || [] existing.push(relPath) routeMap.set(routeValue, existing) } } let hasDuplicate = false for (const [route, routeFiles] of routeMap.entries()) { if (routeFiles.length > 1) { for (const f of routeFiles) { results.push({ code: 'SS007', status: 'error', file: f, message: `Duplicate NavRoute "${route}" — also in: ${routeFiles.filter(x => x !== f).join(', ')}`, }) } hasDuplicate = true } } if (!hasDuplicate) { results.push({ code: 'SS007', status: 'ok', count: routeMap.size }) } return results } /** SS008: Service sans DbContext */ function checkSS008(files: string[], srcDir: string): CheckResult[] { const results: CheckResult[] = [] if (files.length === 0) { results.push({ code: 'SS008', status: 'skipped', message: 'No service files found' }) return results } let okCount = 0 for (const file of files) { const content = readFileSync(file, 'utf-8') const lines = content.split(/\r?\n/) const relPath = relative(srcDir, file) const dbContextMatch = /DbContext|ICoreDbContext/.test(content) if (dbContextMatch) { const lineNum = lines.findIndex(l => /DbContext|ICoreDbContext/.test(l)) results.push({ code: 'SS008', status: 'error', file: relPath, line: lineNum >= 0 ? lineNum + 1 : undefined, message: 'Service uses DbContext directly — must use IRepository', }) } else { okCount++ } } if (okCount > 0 && results.filter(r => r.status === 'error').length === 0) { results.unshift({ code: 'SS008', status: 'ok', count: okCount }) } return results } /** SS009: Repository sans logique metier */ function checkSS009(files: string[], srcDir: string): CheckResult[] { const results: CheckResult[] = [] if (files.length === 0) { results.push({ code: 'SS009', status: 'skipped', message: 'No repository files found' }) return results } let okCount = 0 let hasError = false for (const file of files) { const lines = readLines(file) const relPath = relative(srcDir, file) let fileOk = true for (let i = 0; i < lines.length; i++) { const line = lines[i] // Detect throw ...Exception, but allow NotFoundException and ArgumentNullException const throwMatch = line.match(/throw\s+new\s+(\w*Exception)/) if (throwMatch) { const exType = throwMatch[1] const allowed = ['NotFoundException', 'ArgumentNullException', 'KeyNotFoundException'] if (!allowed.some(a => exType.includes(a))) { results.push({ code: 'SS009', status: 'error', file: relPath, line: i + 1, message: `Repository throws ${exType} — business logic belongs in Service layer`, }) fileOk = false hasError = true } } } if (fileOk) okCount++ } if (okCount > 0 && !hasError) { results.unshift({ code: 'SS009', status: 'ok', count: okCount }) } return results } /** SS010: Entity herite BaseEntity */ function checkSS010(files: string[], srcDir: string): CheckResult[] { const results: CheckResult[] = [] if (files.length === 0) { results.push({ code: 'SS010', status: 'skipped', message: 'No entity files found in Domain' }) return results } let okCount = 0 for (const file of files) { const content = readFileSync(file, 'utf-8') const relPath = relative(srcDir, file) // Check for class declaration inheriting BaseEntity const hasClassDecl = /class\s+\w+/.test(content) if (!hasClassDecl) continue // not a class file, skip // Must check it is an entity class (has a class declaration) and inherits BaseEntity const inheritsBase = /class\s+\w+[^{]*:\s*[^{]*BaseEntity/.test(content) // Skip abstract base classes, interfaces, enums, configuration classes, etc. const isAbstract = /abstract\s+class/.test(content) const isConfig = /IEntityTypeConfiguration/.test(content) const isInterface = /^\s*public\s+interface\s/m.test(content) const isEnum = /^\s*public\s+enum\s/m.test(content) if (isAbstract || isConfig || isInterface || isEnum) continue if (inheritsBase) { okCount++ } else { results.push({ code: 'SS010', status: 'error', file: relPath, message: 'Entity class does not inherit BaseEntity', }) } } if (okCount > 0 && results.filter(r => r.status === 'error').length === 0) { results.unshift({ code: 'SS010', status: 'ok', count: okCount }) } else if (results.length === 0) { results.push({ code: 'SS010', status: 'skipped', message: 'No entity classes found in Domain' }) } return results } // ─── Orchestration ─── export function execute(input: RoslynInput): RoslynReport { const { src, layer } = input // Collect files per pattern const controllerFiles = (layer === 'all' || layer === 'controller') ? findCsFiles(src, /Controller\.cs$/) : [] const serviceFiles = (layer === 'all' || layer === 'service') ? findCsFiles(src, /Service\.cs$/) : [] const repositoryFiles = (layer === 'all' || layer === 'repository') ? findCsFiles(src, /Repository\.cs$/) : [] // For entities, look specifically in Domain directories const entityFiles = (layer === 'all' || layer === 'entity') ? findEntityFiles(src) : [] const checks: CheckResult[] = [] // Controller rules (SS001-SS007) if (layer === 'all' || layer === 'controller') { checks.push(...checkSS001(controllerFiles, src)) checks.push(...checkSS002(controllerFiles, src)) checks.push(...checkSS003(controllerFiles, src)) checks.push(...checkSS004(controllerFiles, src)) checks.push(...checkSS005(controllerFiles, src)) checks.push(...checkSS006(controllerFiles, src)) checks.push(...checkSS007(controllerFiles, src)) } // Service rule (SS008) if (layer === 'all' || layer === 'service') { checks.push(...checkSS008(serviceFiles, src)) } // Repository rule (SS009) if (layer === 'all' || layer === 'repository') { checks.push(...checkSS009(repositoryFiles, src)) } // Entity rule (SS010) if (layer === 'all' || layer === 'entity') { checks.push(...checkSS010(entityFiles, src)) } const errors = checks.filter(c => c.status === 'error').length const warnings = checks.filter(c => c.status === 'warning').length return { timestamp: new Date().toISOString(), checks, errors, warnings, } } /** Find entity files in Domain directories */ function findEntityFiles(src: string): string[] { // Look for .cs files inside directories named "Domain", "Entities", or "Models" // that are likely entity classes const domainFiles: string[] = [] function scanForDomainDirs(dir: string): void { let entries: string[] try { entries = readdirSync(dir) } catch { return } for (const entry of entries) { const fullPath = join(dir, entry) let stat try { stat = statSync(fullPath) } catch { continue } if (!stat.isDirectory()) continue if (entry === 'bin' || entry === 'obj' || entry === 'node_modules' || entry === '.git') continue const lowerEntry = entry.toLowerCase() // Match exact "Domain"/"Entities" or patterns like "Ba011.Domain" if (lowerEntry === 'domain' || lowerEntry === 'entities' || lowerEntry.endsWith('.domain') || lowerEntry.endsWith('.entities')) { // Collect all .cs files in this directory tree domainFiles.push(...findCsFiles(fullPath)) } else { scanForDomainDirs(fullPath) } } } scanForDomainDirs(src) // If no Domain/Entities directories found, return empty // (don't scan entire project — too many false positives) return domainFiles }