/** * cli:readiness-report — execute.ts * Appelle les 3 validators (roslyn, eslint, cross-validate) et agregge les resultats * Ne fait PAS de validation d'input — c'est le role de validate.ts */ import { execSync } from 'node:child_process' import type { ReadinessInput, ReadinessReport, SourceScore } from './types' // ─── Constants ─── /** Roslyn: 10 rules, 4 pts each = 40 max */ const ROSLYN_RULES_COUNT = 10 const ROSLYN_PTS_PER_RULE = 4 const ROSLYN_MAX = ROSLYN_RULES_COUNT * ROSLYN_PTS_PER_RULE // 40 /** ESLint: 5 rules, 5 pts each = 25 max */ const ESLINT_RULES_COUNT = 5 const ESLINT_PTS_PER_RULE = 5 const ESLINT_MAX = ESLINT_RULES_COUNT * ESLINT_PTS_PER_RULE // 25 /** Cross-validate: 5 checks, 7 pts each = 35 max */ const CROSS_CHECKS_COUNT = 5 const CROSS_PTS_PER_CHECK = 7 const CROSS_MAX = CROSS_CHECKS_COUNT * CROSS_PTS_PER_CHECK // 35 // ─── Run a validator subprocess and parse JSON output ─── function runValidator(cmd: string, cwd: string): any { try { const output = execSync(cmd, { encoding: 'utf-8', timeout: 60000, cwd, stdio: ['pipe', 'pipe', 'pipe'], }) return JSON.parse(output) } catch (e: any) { // Validator may exit non-zero (errors found) but still produce JSON on stdout if (e.stdout) { try { return JSON.parse(e.stdout) } catch { /* fall through */ } } return null } } // ─── Roslyn scoring ─── function scoreRoslyn(report: any): SourceScore { if (!report || !Array.isArray(report.checks)) { return { score: 0, max: ROSLYN_MAX, status: 'skipped', errors: 0, details: ['roslyn-validator did not produce a valid report'], } } // Collect unique rule codes that have errors const errorCodes = new Set() const allCodes = new Set() const errorDetails: string[] = [] for (const check of report.checks) { if (check.code) allCodes.add(check.code) if (check.status === 'error') { errorCodes.add(check.code) const detail = check.file ? `${check.code}: ${check.file}${check.line ? ':' + check.line : ''} -- ${check.message || ''}` : `${check.code}: ${check.message || 'error'}` errorDetails.push(detail) } } // Score: 4 pts per rule without errors (out of 10 rules) const rulesWithErrors = errorCodes.size const rulesOk = ROSLYN_RULES_COUNT - rulesWithErrors const score = Math.max(0, rulesOk * ROSLYN_PTS_PER_RULE) const status = rulesWithErrors === 0 ? 'ok' : rulesWithErrors <= 2 ? 'warning' : 'error' return { score, max: ROSLYN_MAX, status, errors: rulesWithErrors, details: errorDetails, } } // ─── ESLint scoring ─── function scoreEslint(report: any): SourceScore { if (!report || !Array.isArray(report.checks)) { return { score: 0, max: ESLINT_MAX, status: 'skipped', errors: 0, details: ['eslint-validator did not produce a valid report'], } } // Collect unique rule codes that have errors const errorCodes = new Set() const errorDetails: string[] = [] for (const check of report.checks) { if (check.status === 'error') { errorCodes.add(check.code) const detail = check.file ? `${check.code}: ${check.file}${check.line ? ':' + check.line : ''} -- ${check.message || ''}` : `${check.code}: ${check.message || 'error'}` errorDetails.push(detail) } } // Score: 5 pts per rule without errors (out of 5 rules) const rulesWithErrors = errorCodes.size const rulesOk = ESLINT_RULES_COUNT - rulesWithErrors const score = Math.max(0, rulesOk * ESLINT_PTS_PER_RULE) const status = rulesWithErrors === 0 ? 'ok' : rulesWithErrors <= 1 ? 'warning' : 'error' return { score, max: ESLINT_MAX, status, errors: rulesWithErrors, details: errorDetails, } } // ─── Cross-validate scoring ─── function scoreCrossValidate(report: any): SourceScore { if (!report) { return { score: 0, max: CROSS_MAX, status: 'skipped', errors: 0, details: ['cross-validate-stack did not produce a valid report'], } } // cross-validate may have { checks: [...] } or { blockers: [...], warnings: [...] } const errorDetails: string[] = [] if (Array.isArray(report.checks)) { // Same format as roslyn/eslint: checks array with code/status const errorCodes = new Set() for (const check of report.checks) { if (check.status === 'error') { errorCodes.add(check.code) const detail = check.file ? `${check.code}: ${check.file} -- ${check.message || ''}` : `${check.code}: ${check.message || 'error'}` errorDetails.push(detail) } } // RETIRED checks (cross-validate 2026-08: permissions-aligned + // rbac-consistent, status 'skipped' + a details string starting RETIRED) // are EXCLUDED from the axis instead of counting as free points — with // the naive `5 - errors` they granted 14/35 unconditionally and a GO ≥ 90 // became structurally easier than before the retirement. const retired = report.checks.filter( (c: any) => c.status === 'skipped' && String(c.details ?? '').startsWith('RETIRED'), ).length const active = Math.max(1, CROSS_CHECKS_COUNT - retired) if (retired > 0) { errorDetails.push(`${retired} retired check(s) excluded from the axis (scored over ${active} active checks)`) } const checksWithErrors = errorCodes.size const checksOk = Math.max(0, active - checksWithErrors) const score = Math.round((checksOk / active) * CROSS_MAX) const status = checksWithErrors === 0 ? 'ok' : checksWithErrors <= 1 ? 'warning' : 'error' return { score, max: CROSS_MAX, status, errors: checksWithErrors, details: errorDetails } } // Fallback: blockers/warnings format (stub format) if (Array.isArray(report.blockers)) { for (const b of report.blockers) { errorDetails.push(String(b)) } } const blockerCount = Array.isArray(report.blockers) ? report.blockers.length : 0 const checksOk = CROSS_CHECKS_COUNT - blockerCount const score = Math.max(0, checksOk * CROSS_PTS_PER_CHECK) const status = blockerCount === 0 ? 'ok' : blockerCount <= 1 ? 'warning' : 'error' return { score, max: CROSS_MAX, status, errors: blockerCount, details: errorDetails } } // ─── Determine recommendation ─── function getRecommendation(total: number): 'GO' | 'NO-GO' | 'STOP' { if (total >= 90) return 'GO' if (total >= 70) return 'NO-GO' return 'STOP' } // ─── Main execute function ─── export function execute(input: ReadinessInput): ReadinessReport { const { specs, src } = input // CLI is always invoked from the project root (cwd) const projectRoot = process.cwd() const roslynCmd = `npx --prefer-offline tsx skills/validation/roslyn/cli/index.ts --src "${src}"` const eslintCmd = `npx --prefer-offline tsx skills/validation/eslint/cli/index.ts --src "${src}"` const crossCmd = `npx --prefer-offline tsx skills/validation/cross-validate/cli/index.ts --specs "${specs}" --src "${src}"` // ─── Run validators ─── const roslynOutput = runValidator(roslynCmd, projectRoot) const eslintOutput = runValidator(eslintCmd, projectRoot) const crossOutput = runValidator(crossCmd, projectRoot) // ─── Score each source ─── const roslynScore = scoreRoslyn(roslynOutput) const eslintScore = scoreEslint(eslintOutput) const crossScore = scoreCrossValidate(crossOutput) // ─── Aggregate ─── const total = roslynScore.score + eslintScore.score + crossScore.score const recommendation = getRecommendation(total) // ─── Collect blockers ─── const blockers: string[] = [] if (roslynScore.details.length > 0) blockers.push(...roslynScore.details) if (eslintScore.details.length > 0) blockers.push(...eslintScore.details) if (crossScore.details.length > 0) blockers.push(...crossScore.details) return { timestamp: new Date().toISOString(), scores: { roslyn: roslynScore, eslint: eslintScore, 'cross-validate': crossScore, }, total, max: 100, recommendation, blockers, } }