/** * cli:audit-ba — rules/sections.ts (SEC-001..008 + XAPP-001..003 — SEC-006 * retired). Mirrors business-analyse/audit-sections/SKILL.md. * SEC-003/004, SEC-008's arbitration and XAPP-002/003 are judgment * (contextual analysis REQUIRED by the SKILL); SEC-007 is the deterministic * orphan-code sweep (the reconcile-menu engine's audit half). */ import { AMBIGUOUS_SECTION_NAMES, XAPP_SECTION_PATTERNS } from '../../../../../lib/ba-menu-tree.js' import { parseIndexDoc } from '../corpus/model.js' import { finding, fold, moduleScope, type RuleDef } from './registry.js' import type { Finding, JudgmentItem } from '../types.js' const DIM = 'sections' as const // SEC-005 / XAPP-001 vocabularies: SINGLE SOURCE in lib/ba-menu-tree.ts — // create-menu/cli/menu-node warns on the same names in its `check` mode. const AMBIGUOUS_NAMES = AMBIGUOUS_SECTION_NAMES const STATE_VARIANT_RE = /-(editees?|archivees?|cloturees?|en-attente|valides?|termines?)$|historique|archive|20\d{2}$/i const XAPP_PATTERNS = XAPP_SECTION_PATTERNS /** Downstream long codes: `(UC|SCR|BR|RBAC)-{APP}-{MOD}-{SEC…}-NNN`. */ const LONG_CODE_RE = /\b(UC|SCR|BR|RBAC)-((?:[A-Za-z0-9_]+-){3,})\d{3}\b/g export const SECTION_RULES: RuleDef[] = [ { id: 'SEC-001', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! return { findings: [ m.sections.length === 0 ? finding('SEC-001', DIM, 'err', moduleScope(m), `${m.app}/${m.module} : 0 section — aucune UI exposable.`) : finding('SEC-001', DIM, 'ok', moduleScope(m), `${m.sections.length} section(s).`), ], } }, }, { id: 'SEC-002', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const seen = new Map() for (const s of m.sections) seen.set(fold(s), (seen.get(fold(s)) ?? 0) + 1) const dups = [...seen.entries()].filter(([, n]) => n > 1).map(([c]) => c) return { findings: [ dups.length > 0 ? finding('SEC-002', DIM, 'err', moduleScope(m), 'Codes de section dupliqués dans le module.', dups) : finding('SEC-002', DIM, 'ok', moduleScope(m), 'Codes de section uniques dans le module.'), ], } }, }, { id: 'SEC-003', dimension: DIM, scope: 'module', kind: 'judgment', evaluate({ model, module }) { const m = module! const judgments: JudgmentItem[] = [] for (const s of m.sections) { for (const other of model.modules.filter((x) => x.app === m.app && x.module !== m.module)) { if (other.sections.some((os) => fold(os) === fold(s))) { judgments.push({ ruleId: 'SEC-003', dimension: DIM, scope: moduleScope(m), maxSeverity: 'warn', question: `Section « ${s} » présente dans ${m.module} ET ${other.module} : vrai doublon fonctionnel ou contextes différents ?`, excerpts: [`${m.app}/${m.module}/${s}`, `${other.app}/${other.module}/${s}`], }) } } } return { findings: [ finding('SEC-003', DIM, 'ok', moduleScope(m), judgments.length > 0 ? `${judgments.length} nom(s) de section partagés inter-modules à arbitrer (jugement).` : 'Aucun nom de section partagé inter-modules.'), ], judgments, } }, }, { id: 'SEC-004', dimension: DIM, scope: 'module', kind: 'judgment', evaluate({ model, module }) { const m = module! const judgments: JudgmentItem[] = [] for (const s of m.sections) { for (const other of model.modules.filter((x) => x.app !== m.app)) { if (other.sections.some((os) => fold(os) === fold(s))) { judgments.push({ ruleId: 'SEC-004', dimension: DIM, scope: moduleScope(m), maxSeverity: 'warn', question: `Section « ${s} » présente dans ${m.app} ET ${other.app} : doublon fonctionnel inter-applications ?`, excerpts: [`${m.app}/${m.module}/${s}`, `${other.app}/${other.module}/${s}`], }) } } } return { findings: [ finding('SEC-004', DIM, 'ok', moduleScope(m), judgments.length > 0 ? `${judgments.length} nom(s) de section partagés inter-applications à arbitrer (jugement).` : 'Aucun nom de section partagé inter-applications.'), ], judgments, } }, }, { id: 'SEC-005', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ module }) { const m = module! const ambiguous = m.sections.filter((s) => AMBIGUOUS_NAMES.has(fold(s))) return { findings: [ ambiguous.length > 0 ? finding('SEC-005', DIM, 'warn', moduleScope(m), 'Nom(s) de section ambigus (settings/options/divers/…).', ambiguous) : finding('SEC-005', DIM, 'ok', moduleScope(m), 'Aucun nom de section ambigu.'), ], } }, }, { id: 'SEC-007', dimension: DIM, scope: 'module', kind: 'mechanical', evaluate({ model, module }) { const m = module! const orphans: string[] = [] const docs = model.docs.filter((d) => d.app === m.app && d.module === m.module && ['use-case', 'screen', 'regles', 'rbac'].includes(d.kind)) for (const doc of docs) { for (const t of doc.raw.matchAll(LONG_CODE_RE)) { const segs = t[2]!.split('-').filter((s) => s !== '') // [APP, MOD, ...sec] if (segs.length < 3) continue const appSeg = segs[0]! const modSeg = segs[1]! const secKebab = segs.slice(2).map((s) => s.toLowerCase().replace(/_/g, '-')).join('-') const appM = model.apps.find((a) => fold(a.app) === fold(appSeg)) if (!appM) { orphans.push(`${t[0]} (${doc.relPath}) : application « ${appSeg} » introuvable`) continue } const modM = model.modules.find((x) => fold(x.app) === fold(appSeg) && fold(x.module) === fold(modSeg)) if (!modM) { orphans.push(`${t[0]} (${doc.relPath}) : module « ${appSeg}/${modSeg} » introuvable`) continue } const sections = new Set(modM.sections.map((s) => fold(s))) // Multi-segment tails may carry a resource: accept section, section // prefix, or resource tail resolving under an existing section. const parts = secKebab.split('-') let resolved = sections.has(secKebab) for (let cut = 1; !resolved && cut < parts.length; cut++) { resolved = sections.has(parts.slice(0, cut).join('-')) || sections.has(parts.slice(cut).join('-')) } if (!resolved) orphans.push(`${t[0]} (${doc.relPath}) : section « ${secKebab} » absente du menu`) } } return { findings: [ orphans.length > 0 ? finding('SEC-007', DIM, 'err', moduleScope(m), 'Codes aval orphelins après édition du menu — /ba-reconcile-menu.', [...new Set(orphans)]) : finding('SEC-007', DIM, 'ok', moduleScope(m), 'Tous les codes aval (UC/SCR/BR/RBAC) résolvent vers une section existante.'), ], } }, }, { id: 'SEC-008', dimension: DIM, scope: 'module', kind: 'hybrid', evaluate({ module }) { const m = module! const judgments: JudgmentItem[] = [] const errs: string[] = [] for (const s of m.sections) { if (!STATE_VARIANT_RE.test(s)) continue // A signal alone: find the sibling sharing the primary subject. const stem = fold(s).replace(STATE_VARIANT_RE, '').replace(/-+$/, '') const sibling = m.sections.find((x) => x !== s && (fold(x) === stem || stem.startsWith(fold(x)) || fold(x).startsWith(stem))) if (!sibling) continue const ctx = parseIndexDoc(m.sectionNodes.find((n) => n.folder === s)?.indexRaw ?? null).contexte if (ctx === '') { errs.push(`${s} + ${sibling} : sujet partagé, la variante n'apporte aucun Contexte propre (filtre d'état déguisé en espace de travail)`) } else { judgments.push({ ruleId: 'SEC-008', dimension: DIM, scope: moduleScope(m), maxSeverity: 'err', question: `La section « ${s} » apporte-t-elle ses PROPRES colonnes/actions/permissions vs « ${sibling} », ou n'est-elle qu'un filtre d'état ?`, excerpts: [`Contexte de ${s} :\n${ctx.slice(0, 400)}`], }) } } const findings: Finding[] = [ errs.length > 0 ? finding('SEC-008', DIM, 'err', moduleScope(m), 'Section(s) variante d’état d’une sœur — replier en filtre/onglet de statut ou requalifier en ressource.', errs) : finding('SEC-008', DIM, 'ok', moduleScope(m), judgments.length > 0 ? `${judgments.length} variante(s) d'état candidates à arbitrer (jugement).` : 'Aucune section variante d’état d’une sœur.'), ] return { findings, judgments } }, }, { id: 'XAPP-001', dimension: DIM, scope: 'project', kind: 'mechanical', evaluate({ model }) { if (model.apps.length < 2) { return { findings: [finding('XAPP-001', DIM, 'ok', {}, 'Une seule application — motifs inter-apps sans objet.')] } } const hits: string[] = [] for (const p of XAPP_PATTERNS) { const apps = new Set() for (const m of model.modules) { if (fold(m.module) === p || m.sections.some((s) => fold(s) === p)) apps.add(m.app) } if (apps.size >= 2) hits.push(`« ${p} » dans ${[...apps].join(', ')}`) } return { findings: [ hits.length > 0 ? finding('XAPP-001', DIM, 'warn', {}, 'Motif de section/module répété dans plusieurs applications.', hits) : finding('XAPP-001', DIM, 'ok', {}, 'Aucun motif répété inter-applications.'), ], } }, }, { id: 'XAPP-002', dimension: DIM, scope: 'project', kind: 'judgment', evaluate({ model }) { const judgments: JudgmentItem[] = [ { ruleId: 'XAPP-002', dimension: DIM, scope: {}, maxSeverity: 'warn', question: 'Une application standard du domaine manque-t-elle ? (JAMAIS un domaine couvert par une app plateforme intégrée — administration/support/hr/api/myspace ni Auth/Notifications/Workflows/Data-export ; jamais « GED/Documents » — IFileStorageService est le socle ; les besoins RH = modules SOUS hr.)', excerpts: model.apps.map((a) => `${a.app} — ${parseIndexDoc(a.indexRaw).title}`), }, ] return { findings: [finding('XAPP-002', DIM, 'ok', {}, 'Couverture applicative du domaine à arbitrer (jugement requis).')], judgments, } }, }, { id: 'XAPP-003', dimension: DIM, scope: 'project', kind: 'judgment', evaluate({ model }) { if (model.apps.length < 2) { return { findings: [finding('XAPP-003', DIM, 'ok', {}, 'Une seule application — recouvrement inter-apps sans objet.')] } } const judgments: JudgmentItem[] = [ { ruleId: 'XAPP-003', dimension: DIM, scope: {}, maxSeverity: 'warn', question: 'Des modules d’applications différentes couvrent-ils la même fonction métier ?', excerpts: model.modules.map((m) => `${m.app}/${m.module} — ${parseIndexDoc(m.indexRaw).title}`), }, ] return { findings: [finding('XAPP-003', DIM, 'ok', {}, 'Recouvrement fonctionnel inter-apps à arbitrer (jugement requis).')], judgments, } }, }, ]