/** * cli:cross-validate-stack — execute.ts * 5 checks de coherence cross-stack backend .NET / frontend React * Ne fait PAS de validation d'input — c'est le role de validate.ts */ import { readFileSync, readdirSync, statSync } from 'node:fs' import { join, relative } from 'node:path' import type { CrossValidateInput, CrossValidateReport, CheckResult, SpecEndpoints, SpecMeta, SpecPermissions, } from './types' // ─── Utilitaires ─── /** Parcours recursif pour trouver des fichiers par extension */ function findFiles(dir: string, extension: string): 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()) { if (entry === 'bin' || entry === 'obj' || entry === 'node_modules' || entry === '.git') continue results.push(...findFiles(fullPath, extension)) } else if (entry.endsWith(extension)) { results.push(fullPath) } } return results } /** Parcours recursif pour trouver des fichiers par pattern regex sur le nom */ function findFilesByPattern(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()) { if (entry === 'bin' || entry === 'obj' || entry === 'node_modules' || entry === '.git') continue results.push(...findFilesByPattern(fullPath, pattern)) } else if (pattern.test(entry)) { results.push(fullPath) } } return results } /** Trouve tous les fichiers JSON nommes exactement filename dans un repertoire recursif */ function findJsonFiles(dir: string, filename: string): 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()) { results.push(...findJsonFiles(fullPath, filename)) } else if (entry === filename) { results.push(fullPath) } } return results } /** Lit et parse un fichier JSON, retourne null si echec */ function readJson(filePath: string): T | null { try { const content = readFileSync(filePath, 'utf-8') return JSON.parse(content) as T } catch { return null } } /** Normalise une route en supprimant les contraintes de type (ex: {id:guid} -> {id}) */ function normalizeRoute(route: string): string { return route.replace(/\{(\w+):[^}]+\}/g, '{$1}') } /** Lit le contenu d'un fichier en string */ function readFile(filePath: string): string { try { return readFileSync(filePath, 'utf-8') } catch { return '' } } // ─── Structures internes ─── interface ResourceSpec { /** Chemin relatif du dossier spec (ex: crm/clients) */ path: string meta: SpecMeta endpoints: SpecEndpoints | null permissions: SpecPermissions | null } /** Charge toutes les specs depuis le repertoire specs */ function loadAllSpecs(specsDir: string): ResourceSpec[] { const metaFiles = findJsonFiles(specsDir, 'meta.json') const resources: ResourceSpec[] = [] for (const metaFile of metaFiles) { const meta = readJson(metaFile) if (!meta) continue // Le dossier parent du meta.json contient les autres fichiers const specDir = join(metaFile, '..') const relPath = relative(specsDir, specDir) const endpoints = readJson(join(specDir, 'endpoints.json')) const permissions = readJson(join(specDir, 'permissions.json')) resources.push({ path: relPath, meta, endpoints, permissions }) } return resources } // ─── Check 1: routes-have-endpoints ─── // Verifie que chaque endpoint specifie dans les specs a un controller .NET correspondant function checkRoutesHaveEndpoints( resources: ResourceSpec[], csFiles: string[], srcDir: string, ): CheckResult { if (resources.length === 0) { return { id: 'routes-have-endpoints', status: 'skipped', details: 'No specs found' } } // Extraire les routes exposees par les controllers .cs // Pattern: [HttpGet], [HttpPost], [HttpPut], [HttpDelete], [HttpPatch] const controllerRoutes = new Map>() for (const file of csFiles) { const content = readFile(file) // Trouver le NavRoute pour identifier la ressource const navMatch = content.match(/\[NavRoute\("([^"]+)"\)\]/) if (!navMatch) continue const navRoute = navMatch[1] // Extraire les verbes HTTP const httpRegex = /\[Http(Get|Post|Put|Delete|Patch)(?:\("([^"]*)"\))?\]/g let match const verbs = new Set() while ((match = httpRegex.exec(content)) !== null) { const verb = match[1].toUpperCase() const route = normalizeRoute(match[2] || '') verbs.add(`${verb}:${route}`) } controllerRoutes.set(navRoute, verbs) } const missing: string[] = [] let matchCount = 0 for (const res of resources) { if (!res.endpoints) continue // Construire le navRoute attendu: module.section const expectedNavRoute = `${res.meta.module}.${res.meta.section}` const controllerVerbs = controllerRoutes.get(expectedNavRoute) for (const ep of res.endpoints.endpoints) { if (!ep.verb) continue // skip malformed endpoint entries const epKey = `${ep.verb.toUpperCase()}:${normalizeRoute(ep.route || '')}` if (controllerVerbs && controllerVerbs.has(epKey)) { matchCount++ } else { missing.push(`${expectedNavRoute} ${ep.verb} /${ep.route || ''}`) } } } if (missing.length > 0) { return { id: 'routes-have-endpoints', status: 'error', details: `Missing controllers for: ${missing.join('; ')}`, } } return { id: 'routes-have-endpoints', status: 'ok', count: matchCount } } // ─── Check 2: endpoints-are-consumed ─── // Verifie que chaque endpoint .NET est appele par au moins un service frontend function checkEndpointsAreConsumed( csFiles: string[], tsFiles: string[], srcDir: string, ): CheckResult { if (csFiles.length === 0) { return { id: 'endpoints-are-consumed', status: 'skipped', details: 'No controller files found' } } // Extraire les endpoints des controllers .cs interface CsEndpoint { navRoute: string verb: string route: string } const csEndpoints: CsEndpoint[] = [] for (const file of csFiles) { const content = readFile(file) const navMatch = content.match(/\[NavRoute\("([^"]+)"\)\]/) if (!navMatch) continue const navRoute = navMatch[1] const httpRegex = /\[Http(Get|Post|Put|Delete|Patch)(?:\("([^"]*)"\))?\]/g let match while ((match = httpRegex.exec(content)) !== null) { csEndpoints.push({ navRoute, verb: match[1].toUpperCase(), route: normalizeRoute(match[2] || ''), }) } } if (csEndpoints.length === 0) { return { id: 'endpoints-are-consumed', status: 'skipped', details: 'No endpoints found in controllers' } } // Lire tout le contenu TS/TSX pour chercher les appels API let allTsContent = '' for (const file of tsFiles) { allTsContent += readFile(file) + '\n' } if (allTsContent.trim().length === 0) { // Pas de fichiers frontend: tout est "non-consomme" const uncovered = csEndpoints.map(ep => `${ep.navRoute} ${ep.verb} /${ep.route}`) return { id: 'endpoints-are-consumed', status: 'warning', details: `No frontend files found. Uncovered: ${uncovered.join('; ')}`, } } const notConsumed: string[] = [] let consumedCount = 0 for (const ep of csEndpoints) { // Chercher des patterns courants d'appel API dans le frontend: // - fetch/axios/apiFetch avec le verbe (get, post, delete, put, patch) // - Le navRoute ou une URL derivee (ex: crm/clients, /api/crm/clients) const routeParts = ep.navRoute.split('.') const urlPattern = routeParts.join('/') const verbLower = ep.verb.toLowerCase() // Patterns de recherche: le verbe HTTP ou une URL qui contient la route const hasVerbCall = new RegExp( `(?:${verbLower}|${ep.verb}).*${urlPattern.replace(/\./g, '\\.')}|${urlPattern.replace(/\./g, '/')}.*(?:${verbLower}|${ep.verb})`, 'i', ).test(allTsContent) // Pattern alternatif: juste la presence de la route dans les services const hasRouteRef = allTsContent.includes(urlPattern) || allTsContent.includes(urlPattern.replace(/\./g, '/')) if (hasVerbCall || hasRouteRef) { consumedCount++ } else { notConsumed.push(`${ep.navRoute} ${ep.verb} /${ep.route}`) } } if (notConsumed.length > 0) { return { id: 'endpoints-are-consumed', status: 'warning', details: `Endpoints not consumed by frontend: ${notConsumed.join('; ')}`, } } return { id: 'endpoints-are-consumed', status: 'ok', count: consumedCount } } // ─── Check 3: permissions-aligned — RETIRED (chantier 4.3) ─── // The check compared two sets DISJOINT BY CONSTRUCTION: the .NET side // extracts raw const-EXPRESSIONS (`XPermissions.Clients.Read` — never // resolved to their string value), the React side extracts app-less kebab // STRING keys (`crm.clients.read`). No element can ever appear in both, so // every run either skipped or reported 100% noise. Repairing it would build // a divergent duplicate of the real engines — the axis is covered where it // belongs: DEV-API-033 (every endpoint guarded), DEV-API-021 (controller // constants ⊆ seeded grants) and DEV-UI-011 (page guard ↔ pagespec // permission, const-expressions resolved). The id stays in the report so the // 5-check shape remains stable for consumers. function checkPermissionsAligned(): CheckResult { return { id: 'permissions-aligned', status: 'skipped', details: 'RETIRED — compared const-expressions (.NET) against kebab string keys (React): disjoint by construction, ' + 'pure noise. Covered for real by DEV-API-033 + DEV-API-021 (backend) and DEV-UI-011 (frontend).', } } // ─── Check 4: resources-have-screens ─── // Verifie que chaque ressource a un controller .cs ET au moins une page .tsx function checkResourcesHaveScreens( resources: ResourceSpec[], csFiles: string[], tsxFiles: string[], srcDir: string, ): CheckResult { if (resources.length === 0) { return { id: 'resources-have-screens', status: 'skipped', details: 'No specs found' } } // Indexer les controllers par NavRoute const controllerNavRoutes = new Set() for (const file of csFiles) { const content = readFile(file) const navMatch = content.match(/\[NavRoute\("([^"]+)"\)\]/) if (navMatch) { controllerNavRoutes.add(navMatch[1]) } } // Indexer les pages .tsx par nom de ressource (heuristique sur le nom de fichier) const tsxFilenames = new Set() for (const file of tsxFiles) { const content = readFile(file) // Ajouter le nom du fichier en lowercase const parts = file.replace(/\\/g, '/').split('/') const filename = parts[parts.length - 1].replace(/\.(tsx|ts)$/, '').toLowerCase() tsxFilenames.add(filename) // Aussi chercher les references au module.section dans le contenu const routeRefs = content.match(/['"]([a-z][-a-z0-9]*\.[a-z][-a-z0-9]*)['"]/g) if (routeRefs) { for (const ref of routeRefs) { tsxFilenames.add(ref.replace(/['"]/g, '')) } } } const missing: string[] = [] let okCount = 0 for (const res of resources) { const navRoute = `${res.meta.module}.${res.meta.section}` const resourceLower = res.meta.resource.toLowerCase() const hasController = controllerNavRoutes.has(navRoute) // Chercher une page .tsx qui correspond au nom de la ressource const hasScreen = tsxFilenames.has(resourceLower) || tsxFilenames.has(`${resourceLower}page`) || tsxFilenames.has(`${resourceLower}list`) || tsxFilenames.has(`${resourceLower}s`) || tsxFilenames.has(navRoute) if (hasController && hasScreen) { okCount++ } else { const parts: string[] = [] if (!hasController) parts.push('controller missing') if (!hasScreen) parts.push('screen missing') missing.push(`${navRoute} (${res.meta.resource}): ${parts.join(', ')}`) } } if (missing.length > 0) { return { id: 'resources-have-screens', status: 'error', details: missing.join('; '), } } return { id: 'resources-have-screens', status: 'ok', count: okCount } } // ─── Check 5: rbac-consistent — RETIRED (chantier 4.3) ─── // The check read `res.permissions` from the MCP-era spec layout // (`permissions.json` blocks inside the specs dir) — a layout the v5 // file-based flow never produces, so it skipped on every real run (« No // permissions declared in specs »): a check that could not fail. Its axis — // declared permissions materialized and used — is covered for real by // DEV-CORE-011 (bidirectional rbac.md ⇄ seed-state grant parity) and // DEV-API-021 (controller constants ⊆ seeded grants). The id stays in the // report so the 5-check shape remains stable for consumers. function checkRbacConsistent(): CheckResult { return { id: 'rbac-consistent', status: 'skipped', details: 'RETIRED — read the MCP-era permissions.json spec layout the v5 flow never produces (skipped on every ' + 'real run). Covered for real by DEV-CORE-011 (grant parity) + DEV-API-021 (constants ⊆ seed).', } } // ─── Orchestration ─── export function execute(input: CrossValidateInput): CrossValidateReport { const { specs, src } = input // Charger les specs const resources = loadAllSpecs(specs) // Trouver les fichiers source const csFiles = findFilesByPattern(src, /Controller\.cs$/) const tsFiles = findFiles(src, '.ts') const tsxFiles = findFiles(src, '.tsx') const allTsFiles = [...tsFiles, ...tsxFiles] // Executer les checks (3 et 5 sont retires — ids conserves, voir plus haut) const checks: CheckResult[] = [ checkRoutesHaveEndpoints(resources, csFiles, src), checkEndpointsAreConsumed(csFiles, allTsFiles, src), checkPermissionsAligned(), checkResourcesHaveScreens(resources, csFiles, tsxFiles, src), checkRbacConsistent(), ] 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, } }