/** * cli:audit-ba — rules/screens.ts (SCR-001..025 — module scope). * Mirrors business-analyse/audit-screens/SKILL.md. * SCR-009/014/025 ride ONE checkDeclaredTabs run (derive-related-tabs, direct * import — the same engine as the CLI's validate mode); SCR-024 rides * deriveUcCoverage (BA leg — the PRD leg belongs to /ba-audit-prd PRD-131). */ import { DeriveRelatedTabsInputSchema, } from '../../../../create-screen/cli/derive-related-tabs/types.js' import { checkDeclaredTabs, type CheckContext } from '../../../../create-screen/cli/derive-related-tabs/check.js' import { parseRbacPermissions } from '../../../../create-screen/cli/derive-related-tabs/rbac.js' import { deriveUcCoverage, type ScreenSource } from '../../../../create-screen/cli/derive-uc-coverage/execute.js' import type { CorpusModel, ModuleModel } from '../corpus/model.js' import { HUB_SCREEN_TYPES_RE, parseScreenBlocks, unresolvedEntityRefs, type ScreenBlock } from './screen-blocks.js' import { finding, fold, moduleScope, type RuleDef } from './registry.js' import type { Finding, JudgmentItem } from '../types.js' const DIM = 'screens' as const // The hub exemption lives in screen-blocks (shared with XD-005, the mirror). const HUB_TYPES_RE = HUB_SCREEN_TYPES_RE const ENTITY_BOUND_RE = /^Smart(ListView|Form|Card)$/i function blocksOf(model: CorpusModel, m: ModuleModel): ScreenBlock[] { return model.docs .filter((d) => d.kind === 'screen' && d.app === m.app && d.module === m.module) .flatMap((d) => parseScreenBlocks(d.raw, d.relPath)) } /** Every SCR code of the whole ANALYSIS (navigation resolution table). */ function allScreenCodes(model: CorpusModel): Set { const codes = new Set() for (const d of model.docs) { if (d.kind !== 'screen') continue for (const b of parseScreenBlocks(d.raw, d.relPath)) codes.add(b.code.toUpperCase()) } return codes } /** ONE checkDeclaredTabs run per module (cached per engine invocation). */ const validateCache = new WeakMap | null>() function relatedTabsReport(model: CorpusModel, m: ModuleModel): ReturnType | null { if (validateCache.has(m)) return validateCache.get(m)! let report: ReturnType | null = null try { const app = model.apps.find((a) => a.app === m.app) const rbac = new Set() for (const mod of model.modules.filter((x) => x.app === m.app)) { if (mod.rbacContent !== null) for (const p of parseRbacPermissions(mod.rbacContent)) rbac.add(p) } const ctx: CheckContext = { spec: DeriveRelatedTabsInputSchema.parse({ baRoot: model.baRoot, app: m.app, module: m.module, mode: 'validate' }), graph: model.graph, screens: app?.screens ?? [], rbac, } report = checkDeclaredTabs(ctx) } catch { report = null } validateCache.set(m, report) return report } const ucCoverageCache = new WeakMap>() function ucCoverage(model: CorpusModel, m: ModuleModel): ReturnType { const cached = ucCoverageCache.get(m) if (cached) return cached const screens: ScreenSource[] = model.docs .filter((d) => d.kind === 'screen' && d.app === m.app && d.module === m.module) .map((d) => ({ relPath: d.relPath.split('/').slice(2).join('/'), md: d.raw })) const report = deriveUcCoverage({ app: m.app, module: m.module, ucs: m.ucs, ucWarnings: [], screens, pagespecs: null, }) ucCoverageCache.set(m, report) return report } const CODE_ACTION_RE = /^[a-z][a-zA-Z0-9]*$/ const PERMISSION_RE = /^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)?\.[a-z][a-z0-9-]*$|^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$/ const KEBAB_RE = /^[a-z][a-z0-9-]*$/ const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) const READ_SHAPE_RE = /preview|forecast|simulat|calcul|comput|histor|analys|projection|-at$|pr[eé]vision|historique|simulation|aper[cç]u|bilan/i const INSTANCE_VERBS_RE = /^(freeze|close|validate|approve|reject|suspend|archive|activate|deactivate|lock|unlock|cl[oô]turer|geler|valider|approuver|rejeter|suspendre|archiver)$/i const LATER_PHASE_FIELD_RE = /^(payment|paid|departure|sortie|exit|termination|closure|closed|cancel|resolution|archiv|end)/i export const SCREEN_RULES: RuleDef[] = [ { id: 'SCR-001', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const n = blocksOf(model, m).length return { findings: [ n === 0 ? finding('SCR-001', DIM, 'err', moduleScope(m), `${m.app}/${m.module} : 0 écran — lancer /ba-create-screen.`) : finding('SCR-001', DIM, 'ok', moduleScope(m), `${n} écran(s).`), ], } }, }, { id: 'SCR-002', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const blocks = blocksOf(model, m) const covered = new Set(blocks.map((b) => fold(b.file.split('/').slice(2, -1).join('/')))) const empty = m.sections.filter((s) => ![...covered].some((c) => c === fold(s) || c.startsWith(`${fold(s)}/`))) return { findings: [ empty.length > 0 ? finding('SCR-002', DIM, 'warn', moduleScope(m), 'Section(s) sans aucun écran.', empty) : finding('SCR-002', DIM, 'ok', moduleScope(m), 'Chaque section a ≥ 1 écran.'), ], } }, }, { id: 'SCR-003', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! // The ONE evaluator — XD-005 (dedupOf SCR-003) calls the same function. const broken = unresolvedEntityRefs(blocksOf(model, m), m.entities) return { findings: [ broken.length > 0 ? finding('SCR-003', DIM, 'err', moduleScope(m), 'Référence(s) entité d’écran cassées.', broken) : finding('SCR-003', DIM, 'ok', moduleScope(m), 'Toutes les références entité des écrans résolvent.'), ], } }, }, { id: 'SCR-004', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const missing = blocksOf(model, m) .filter((b) => ENTITY_BOUND_RE.test(b.screenType) && (b.entity === undefined || b.entity === '')) .map((b) => `${b.code} (${b.screenType})`) return { findings: [ missing.length > 0 ? finding('SCR-004', DIM, 'warn', moduleScope(m), 'Écran(s) entity-bound sans référence entité.', missing) : finding('SCR-004', DIM, 'ok', moduleScope(m), 'Tous les écrans entity-bound déclarent leur entité.'), ], } }, }, { id: 'SCR-005', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const unlinked = blocksOf(model, m) .filter((b) => !HUB_TYPES_RE.test(b.screenType)) .filter((b) => b.linkedUcs.length === 0 && !b.actions.some((a) => a.uc !== undefined)) .map((b) => b.code) return { findings: [ unlinked.length > 0 ? finding('SCR-005', DIM, 'warn', moduleScope(m), 'Écran(s) liés à aucun cas d’usage.', unlinked) : finding('SCR-005', DIM, 'ok', moduleScope(m), 'Tous les écrans sont liés à ≥ 1 UC.'), ], } }, }, { id: 'SCR-006', dimension: DIM, scope: 'app', kind: 'mechanical', evaluate({ model, app }) { const a = app! const moduleCount = model.modules.filter((m) => m.app === a.app).length if (moduleCount < 2) { return { findings: [finding('SCR-006', DIM, 'ok', { app: a.app }, 'Moins de 2 modules — SmartAppHome non requis.')] } } const hasHome = a.screens.some((s) => /apphome/i.test(s.screenType)) return { findings: [ hasHome ? finding('SCR-006', DIM, 'ok', { app: a.app }, 'SmartAppHome présent.') : finding('SCR-006', DIM, 'warn', { app: a.app }, `Application à ${moduleCount} modules sans SmartAppHome.`), ], } }, }, { id: 'SCR-007', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! if (m.sections.length < 2) { return { findings: [finding('SCR-007', DIM, 'ok', moduleScope(m), 'Moins de 2 sections — SmartModuleHome non requis.')] } } const hasHome = blocksOf(model, m).some((b) => /modulehome/i.test(b.screenType)) return { findings: [ hasHome ? finding('SCR-007', DIM, 'ok', moduleScope(m), 'SmartModuleHome présent.') : finding('SCR-007', DIM, 'warn', moduleScope(m), `Module à ${m.sections.length} sections sans SmartModuleHome.`), ], } }, }, { id: 'SCR-008', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const codes = allScreenCodes(model) const broken: string[] = [] for (const b of blocksOf(model, m)) { for (const t of new Set(b.scrTokens)) { if (!codes.has(t.toUpperCase())) broken.push(`${b.code} → ${t}`) } } return { findings: [ broken.length > 0 ? finding('SCR-008', DIM, 'err', moduleScope(m), 'Cible(s) de navigation vers un écran inexistant.', [...new Set(broken)]) : finding('SCR-008', DIM, 'ok', moduleScope(m), 'Toutes les cibles de navigation résolvent.'), ], } }, }, { id: 'SCR-009', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const report = relatedTabsReport(model, m) if (report === null) { return { findings: [finding('SCR-009', DIM, 'warn', moduleScope(m), 'Moteur derive-related-tabs indisponible sur ce module — vérification non rendue (jamais un faux vert).')] } } const v = report.violations.filter((x) => x.rule === 'RTV-007') return { findings: [ v.length > 0 ? finding('SCR-009', DIM, 'err', moduleScope(m), 'Formulaire(s) hub sans onglet lié ni marqueur « Sans onglets liés » (vue 360).', v.map((x) => `${x.screenCode} : ${x.message}`)) : finding('SCR-009', DIM, 'ok', moduleScope(m), 'Vue 360 : chaque hub expose ses relations (ou porte sa justification).'), ], } }, }, { id: 'SCR-010', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const legacy = blocksOf(model, m).filter((b) => /^SmartFilter$/i.test(b.screenType)).map((b) => b.code) return { findings: [ legacy.length > 0 ? finding('SCR-010', DIM, 'err', moduleScope(m), 'Écran(s) SmartFilter legacy — replier les filtres dans SmartListView.config.filters.', legacy) : finding('SCR-010', DIM, 'ok', moduleScope(m), 'Aucun SmartFilter legacy.'), ], } }, }, { id: 'SCR-011', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const errs: string[] = [] for (const b of blocksOf(model, m)) { for (const a of b.actions) { const missing: string[] = [] if (!a.code || !CODE_ACTION_RE.test(a.code)) missing.push('code camelCase') if (!a.kind) missing.push('kind') if (!a.scope) missing.push('scope') if (!a.permission || !PERMISSION_RE.test(a.permission)) missing.push('permission (3-4 segments)') if (!a.label) missing.push('label') if (a.kind === 'api') { const endpoint = a.endpoint ?? (a.code ? a.code.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase() : '') if (!KEBAB_RE.test(endpoint)) missing.push('endpoint kebab') if (!a.httpMethod || !HTTP_METHODS.has(a.httpMethod)) missing.push('httpMethod') if (!a.uc) missing.push('UC: UC-…') } else if (a.kind === 'navigate') { if (!a.targetScreen && !a.targetRoute) missing.push('targetScreen ou targetRoute') if (a.endpoint || a.httpMethod) missing.push('endpoint/httpMethod interdits sur navigate') } if (missing.length > 0) errs.push(`${b.code} · \`${a.code ?? '?'}\` : ${missing.join(', ')}`) } } return { findings: [ errs.length > 0 ? finding('SCR-011', DIM, 'err', moduleScope(m), 'Action(s) custom sans les métadonnées obligatoires.', errs) : finding('SCR-011', DIM, 'ok', moduleScope(m), 'Toutes les actions custom portent leurs métadonnées.'), ], } }, }, { id: 'SCR-012', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const seen = new Map() const dups: string[] = [] for (const b of blocksOf(model, m)) { for (const a of b.actions) { if (a.kind !== 'api') continue const endpoint = a.endpoint ?? (a.code ? a.code.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase() : '') const key = `${fold(b.entity ?? '')}|${a.httpMethod ?? ''}|${a.scope ?? ''}|${endpoint}` if (seen.has(key)) dups.push(`${seen.get(key)} ≡ ${b.code}·${a.code} (${a.httpMethod} ${endpoint})`) else seen.set(key, `${b.code}·${a.code}`) } } return { findings: [ dups.length > 0 ? finding('SCR-012', DIM, 'err', moduleScope(m), 'Endpoints d’actions custom dupliqués par entité (collision contrôleur/TS).', dups) : finding('SCR-012', DIM, 'ok', moduleScope(m), 'Endpoints d’actions custom uniques par entité.'), ], } }, }, { id: 'SCR-013', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const codes = allScreenCodes(model) const broken: string[] = [] for (const b of blocksOf(model, m)) { for (const a of b.actions) { if (a.kind === 'navigate' && a.targetScreen && !codes.has(a.targetScreen.toUpperCase())) { broken.push(`${b.code}·${a.code} → ${a.targetScreen}`) } } } return { findings: [ broken.length > 0 ? finding('SCR-013', DIM, 'err', moduleScope(m), 'targetScreen(s) d’actions navigate cassés.', broken) : finding('SCR-013', DIM, 'ok', moduleScope(m), 'Tous les targetScreen des actions navigate résolvent.'), ], } }, }, { id: 'SCR-014', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const report = relatedTabsReport(model, m) if (report === null) { return { findings: [finding('SCR-014', DIM, 'warn', moduleScope(m), 'Moteur derive-related-tabs indisponible — prérequis des onglets liés non vérifiés (jamais un faux vert).')] } } const hard = report.violations.filter((x) => ['RTV-001', 'RTV-002', 'RTV-003', 'RTV-005', 'RTV-006', 'RTV-008'].includes(x.rule)) const soft = report.violations.filter((x) => x.rule === 'RTV-004') const findings: Finding[] = [] if (hard.length > 0) findings.push(finding('SCR-014', DIM, 'err', moduleScope(m), 'Onglet(s) lié(s) déclarés sans leurs prérequis.', hard.map((x) => `${x.screenCode}${x.tabKey ? `·${x.tabKey}` : ''} [${x.rule}] : ${x.message}`))) if (soft.length > 0) findings.push(finding('SCR-014', DIM, 'warn', moduleScope(m), 'Permission d’onglet lié introuvable dans rbac.md (RTV-004).', soft.map((x) => `${x.screenCode}${x.tabKey ? `·${x.tabKey}` : ''} : ${x.message}`))) if (findings.length === 0) findings.push(finding('SCR-014', DIM, 'ok', moduleScope(m), 'Tous les onglets liés satisfont leurs prérequis.')) return { findings } }, }, { id: 'SCR-015', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const coded = new Set(m.entities.filter((e) => e.codePatternRaw !== null).map((e) => fold(e.name))) if (coded.size === 0) { return { findings: [finding('SCR-015', DIM, 'ok', moduleScope(m), 'Aucune entité codée en portée.')] } } // La facette « surchargeable à la création » sanctionne un code OPTIONNEL // sur la surface CREATE (ISuppliedCodeGuard) — le champ y devient légitime. // L'edit reste verrouillé, facette ou pas (un code est immuable après création). const codedSupplied = new Set( m.entities.filter((e) => e.codePattern?.supplied === true).map((e) => fold(e.name)), ) const offending: string[] = [] for (const b of blocksOf(model, m)) { if (!/form/i.test(b.screenType) || !coded.has(fold(b.entity ?? ''))) continue const allFields = [...b.champs, ...b.sectionGroups.flatMap((g) => g.fields), ...b.ongletGroups.flatMap((g) => g.fields)] const codeField = allFields.find((f) => /^code\b/i.test(f.trim())) if (!codeField) continue const readonly = /readonly/i.test(codeField) const isCreate = b.mode === undefined || /create/i.test(b.mode ?? '') const supplied = codedSupplied.has(fold(b.entity ?? '')) if (isCreate && supplied) continue if (isCreate || !readonly) offending.push(`${b.code} : champ « ${codeField} » ${isCreate ? 'présent en create' : 'éditable'} sur l'entité codée ${b.entity}${supplied ? ' (surchargeable ne couvre que le CREATE)' : ''}`) } return { findings: [ offending.length > 0 ? finding('SCR-015', DIM, 'err', moduleScope(m), 'Champ `code` exposé en édition sur une entité à Code pattern (le Code est alloué par le socle — saisi en create UNIQUEMENT si la ligne déclare « surchargeable à la création »).', offending) : finding('SCR-015', DIM, 'ok', moduleScope(m), 'Aucun champ code éditable hors sanction (facette surchargeable) sur les entités codées.'), ], } }, }, { id: 'SCR-016', dimension: DIM, scope: 'module', kind: 'hybrid', evaluate({ model, module }) { const m = module! const warns: string[] = [] const ATTACHMENT_RE = /documents?|pi[eè]ces? jointes?|attachments?|justificatifs?/i const metadataEntities = m.entities.filter((e) => e.attributes.some((a) => /stored/i.test(a.name)) && e.attributes.some((a) => /contenttype/i.test(a.name))) for (const b of blocksOf(model, m)) { if (b.raw.match(/payloadParameters[^\n]*type\s*:\s*'?file'?/i)) { warns.push(`${b.code} : action custom avec paramètre type:file — le pipeline poste du JSON, jamais du multipart (endpoints dédiés sur l'entité métadonnées)`) } const attachmentish = ATTACHMENT_RE.test(b.title) || b.ongletGroups.some((g) => ATTACHMENT_RE.test(g.label)) if (attachmentish && metadataEntities.length === 0) { warns.push(`${b.code} : sémantique pièces jointes sans entité MÉTADONNÉES au MCD (FileName, StoredFileName, ContentType, FileSizeBytes + FK parent)`) } } return { findings: [ warns.length > 0 ? finding('SCR-016', DIM, 'warn', moduleScope(m), 'Écrans pièces jointes hors pattern métadonnées / upload par action custom.', warns, undefined, { relatedTo: 'DM-019' }) : finding('SCR-016', DIM, 'ok', moduleScope(m), 'Écrans pièces jointes conformes (ou aucun).'), ], } }, }, { id: 'SCR-017', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const warns: string[] = [] for (const b of blocksOf(model, m)) { if (!/listview/i.test(b.screenType)) continue if (b.columns.length > 10) warns.push(`${b.code} : ${b.columns.length} colonnes (> 10)`) if (b.filters.length > 8) warns.push(`${b.code} : ${b.filters.length} filtres (> 8)`) if (b.indicators.length > 4) warns.push(`${b.code} : ${b.indicators.length} indicateurs (> 4)`) for (const ind of b.indicators) { const scoped = /,\s*([A-Za-z][A-Za-z0-9]*)\s*=/.exec(ind) if (scoped && !b.filters.some((f) => fold(f).startsWith(fold(scoped[1]!)))) { warns.push(`${b.code} : indicateur scopé sur « ${scoped[1]} » qui n'est pas un champ Filtres déclaré`) } } } return { findings: [ warns.length > 0 ? finding('SCR-017', DIM, 'warn', moduleScope(m), 'Inflation de liste (Colonnes ≤ 10, Filtres ≤ 8, Indicateurs ≤ 4, indicateur scopé sur un filtre déclaré).', warns) : finding('SCR-017', DIM, 'ok', moduleScope(m), 'Listes dans les budgets de lisibilité.'), ], } }, }, { id: 'SCR-018', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const warns: string[] = [] for (const b of blocksOf(model, m)) { if (!/form/i.test(b.screenType)) continue const grouped = b.sectionGroups.length > 0 || b.ongletGroups.length > 0 if (!grouped && b.champs.length >= 8) warns.push(`${b.code} : ${b.champs.length} champs à plat sans Section/Onglet (mur de champs)`) if (b.sectionGroups.length > 0) { if (b.sectionGroups.length < 2 || b.sectionGroups.length > 4) warns.push(`${b.code} : ${b.sectionGroups.length} section(s) (attendu 2-4)`) for (const g of b.sectionGroups) { if (g.fields.length < 2) warns.push(`${b.code} : section « ${g.label} » à ${g.fields.length} champ (bruit)`) } const labels = b.sectionGroups.map((g) => fold(g.label)) if (new Set(labels).size !== labels.length) warns.push(`${b.code} : labels de sections dupliqués`) } if (b.sectionGroups.length > 0 && b.ongletGroups.length > 0) warns.push(`${b.code} : Section et Onglet mélangés au premier niveau`) if (grouped && b.champs.length > 0) warns.push(`${b.code} : ${b.champs.length} champ(s) restés à plat hors des groupes`) const groupedFields = [...b.sectionGroups, ...b.ongletGroups].flatMap((g) => g.fields.map((f) => fold(f))) const dupField = groupedFields.find((f, i) => groupedFields.indexOf(f) !== i) if (dupField) warns.push(`${b.code} : champ « ${dupField} » listé dans deux groupes`) } return { findings: [ warns.length > 0 ? finding('SCR-018', DIM, 'warn', moduleScope(m), 'Groupement de formulaire hors budget (mur de champs, sections 2-4 × ≥2 champs, pas de mixage, couverture exacte).', warns) : finding('SCR-018', DIM, 'ok', moduleScope(m), 'Formulaires correctement groupés.'), ], } }, }, { id: 'SCR-019', dimension: DIM, scope: 'module', kind: 'hybrid', evaluate({ model, module }) { const m = module! const warns: string[] = [] const stateByEntity = new Map(m.entities.map((e) => [fold(e.name), e.attributes.filter((a) => /status|statut|state|etat|état|phase|step/i.test(a.name) || (/enum/i.test(a.type) && /\//.test(a.constraints)))])) for (const b of blocksOf(model, m)) { if (!/form/i.test(b.screenType)) continue const stateAttrs = stateByEntity.get(fold(b.entity ?? '')) ?? [] if (stateAttrs.length === 0) continue const allFields = [...b.champs, ...b.sectionGroups.flatMap((g) => g.fields), ...b.ongletGroups.flatMap((g) => g.fields)] for (const f of allFields) { const name = fold(f.split(/[\s(]/)[0] ?? '') if (stateAttrs.some((a) => fold(a.name) === name) && !/readonly/i.test(f) && !b.hasCycleDeVie) { warns.push(`${b.code} : champ d'état « ${f} » éditable sans bullet Cycle de vie (l'ancre du lifecycle, pas une saisie libre)`) } if (LATER_PHASE_FIELD_RE.test(name) && !b.hasCycleDeVie) { warns.push(`${b.code} : champ de phase ultérieure « ${f} » demandé à la création sans phase Cycle de vie`) } } } return { findings: [ warns.length > 0 ? finding('SCR-019', DIM, 'warn', moduleScope(m), 'Formulaire create demandant des champs de phase ultérieure (heuristique — le jumeau déterministe aval est PRD-120/121).', warns) : finding('SCR-019', DIM, 'ok', moduleScope(m), 'Aucun champ de phase ultérieure demandé à la création.'), ], } }, }, { id: 'SCR-020', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const errs: string[] = [] for (const node of m.sectionNodes) { const sectionBlocks = blocksOf(model, m).filter((b) => { const rel = b.file.split('/').slice(2) return rel[0] === node.folder }) if (sectionBlocks.length === 0) continue const sectionLevel = sectionBlocks.filter((b) => b.file.split('/').length === 4) // APP/MOD/section/screen.md const resourceLevel = sectionBlocks.filter((b) => b.file.split('/').length > 4) if (resourceLevel.length > 0 && sectionLevel.length === 0) { errs.push(`${node.folder} : ${resourceLevel.length} écran(s) au niveau ressource, aucun écran de section (SmartSectionHome manquant — URL proposée, rien d'enregistré derrière)`) } else if (resourceLevel.length > 0 && !sectionLevel.some((b) => /sectionhome/i.test(b.screenType))) { errs.push(`${node.folder} : ressources sœurs sans SmartSectionHome au niveau section`) } } return { findings: [ errs.length > 0 ? finding('SCR-020', DIM, 'err', moduleScope(m), 'Section à ressources sœurs sans SmartSectionHome.', errs) : finding('SCR-020', DIM, 'ok', moduleScope(m), 'Chaque section à ressources déclare son SmartSectionHome (ou n’a pas de ressources).'), ], } }, }, { id: 'SCR-021', dimension: DIM, scope: 'module', kind: 'hybrid', evaluate({ model, module }) { const m = module! const warns: string[] = [] for (const b of blocksOf(model, m)) { for (const a of b.actions) { if (a.kind !== 'api') continue if (READ_SHAPE_RE.test(a.code ?? '') && !/résultat|result|affiche|retourne|returns|→/.test(a.raw)) { warns.push(`${b.code}·${a.code} : probable action de lecture — déclarez ce qu'elle retourne (gate déterministe aval : PRD-124)`) } } } return { findings: [ warns.length > 0 ? finding('SCR-021', DIM, 'warn', moduleScope(m), 'Action(s) de forme lecture sans résultat observable déclaré.', warns) : finding('SCR-021', DIM, 'ok', moduleScope(m), 'Toutes les actions de lecture déclarent leur résultat (ou aucune).'), ], } }, }, { id: 'SCR-022', dimension: DIM, scope: 'module', kind: 'hybrid', evaluate({ model, module }) { const m = module! const missing = blocksOf(model, m) .filter((b) => /dashboard/i.test(b.screenType) && (b.entity === undefined || b.entity === '')) .map((b) => b.code) return { findings: [ missing.length > 0 ? finding('SCR-022', DIM, 'warn', moduleScope(m), 'SmartDashboard sans entité hôte — rien à appeler côté serveur (gate déterministe aval : PRD-125).', missing) : finding('SCR-022', DIM, 'ok', moduleScope(m), 'Chaque dashboard vit sur une entité (ou aucun dashboard).'), ], } }, }, { id: 'SCR-023', dimension: DIM, scope: 'module', kind: 'hybrid', evaluate({ model, module }) { const m = module! const judgments: JudgmentItem[] = [] for (const b of blocksOf(model, m)) { for (const a of b.actions) { if (INSTANCE_VERBS_RE.test(a.code ?? '') && fold(a.scope ?? '') === 'header' && !/param|instance|id\b/i.test(a.raw)) { judgments.push({ ruleId: 'SCR-023', dimension: DIM, scope: moduleScope(m), maxSeverity: 'warn', question: `${b.code}·${a.code} : verbe d'instance en scope header sans paramètre — traitement de masse ASSUMÉ, ou passer l'action en scope row ?`, excerpts: [a.raw], }) } } } return { findings: [ finding('SCR-023', DIM, 'ok', moduleScope(m), judgments.length > 0 ? `${judgments.length} verbe(s) d'instance en header à confirmer (jugement).` : 'Aucun verbe d’instance en scope header sans paramètre.'), ], judgments, } }, }, { id: 'SCR-024', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const report = ucCoverage(model, m) const uncovered = report.ucs.filter((u) => u.ba === 'uncovered') return { findings: [ uncovered.length > 0 ? finding( 'SCR-024', DIM, 'err', moduleScope(m), 'Cas d’usage user-goal servis par AUCUNE surface (le shape UC fantôme) — lier l’UC sur son écran/action, le marquer scheduled, ou déclarer son niveau.', uncovered.map((u) => `${u.ucCode} — ${u.title} (${u.sourceFile})`), ) : finding('SCR-024', DIM, 'ok', moduleScope(m), `${report.totals.ucs} UC : couverture BA complète (scheduled/exempt inclus).`), ], } }, }, { id: 'SCR-025', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const report = relatedTabsReport(model, m) if (report === null) { return { findings: [finding('SCR-025', DIM, 'warn', moduleScope(m), 'Moteur derive-related-tabs indisponible — budget de la barre d’onglets non vérifié.')] } } const v = report.violations.filter((x) => x.rule === 'RTV-009') const errs = v.filter((x) => x.severity === 'err') const warns = v.filter((x) => x.severity === 'warn') const findings: Finding[] = [] if (errs.length > 0) findings.push(finding('SCR-025', DIM, 'err', moduleScope(m), 'Barre d’onglets partagée > 9 (budget dur).', errs.map((x) => `${x.screenCode} : ${x.message}`))) if (warns.length > 0) findings.push(finding('SCR-025', DIM, 'warn', moduleScope(m), 'Barre d’onglets partagée > 7 (budget indicatif).', warns.map((x) => `${x.screenCode} : ${x.message}`))) if (findings.length === 0) findings.push(finding('SCR-025', DIM, 'ok', moduleScope(m), 'Budget de la barre d’onglets respecté.')) return { findings } }, }, ]