/** * cli:audit-ba — rules/menu.ts (MENU-001..008 — app/project scope). * Mirrors business-analyse/audit-menu/SKILL.md. MENU-001/005 are pure * business judgment (compact excerpts); MENU-002 is hybrid (exact-code * candidates mechanical, semantics judgment); MENU-003/004/006 mechanical * (platform catalogue drift-tested in lib/platform-catalog); * MENU-007/008 mechanical (Contexte substance + Hors-périmètre mandatory * with the explicit empty marker — absence as an ASSERTION). */ import { CONFIG_APP_RE, CONTEXT_MIN_CHARS, EMPTY_HP_MARKER, EXTENSION_DECLARATION_RE, TECHNICAL_FEATURES_RE, } from '../../../../../lib/ba-menu-tree.js' import { matchBuiltinApp, matchBuiltinModule } from '../../../../../lib/platform-catalog.js' import { parseIndexDoc } from '../corpus/model.js' import { finding, fold, type RuleDef } from './registry.js' import type { Finding, JudgmentItem } from '../types.js' const DIM = 'menu' as const /** Generic module codes whose per-app repetition is EXPECTED (never MENU-002). */ const GENERIC_MODULES = new Set(['settings', 'configuration', 'config', 'dashboard', 'reporting', 'rapports', 'reports', 'admin']) // The MECHANICAL constants (CONFIG_APP_RE, TECHNICAL_FEATURES_RE, // EXTENSION_DECLARATION_RE, CONTEXT_MIN_CHARS, EMPTY_HP_MARKER) are the // SINGLE SOURCE in lib/ba-menu-tree.ts — create-menu/cli/menu-node applies // the same ones in its `check` mode, so a node the CLI accepts is a node this // audit accepts. export const MENU_RULES: RuleDef[] = [ { id: 'MENU-001', dimension: DIM, scope: 'app', kind: 'judgment', evaluate({ model, app }) { const a = app! const modules = model.modules.filter((m) => m.app === a.app) if (modules.length === 0) { return { findings: [finding('MENU-001', DIM, 'ok', { app: a.app }, 'Aucun module — couvert par MENU-004.')] } } const appDoc = parseIndexDoc(a.indexRaw) const judgments: JudgmentItem[] = [ { ruleId: 'MENU-001', dimension: DIM, scope: { app: a.app }, maxSeverity: 'warn', question: `Chaque module colle-t-il sémantiquement à l'application ${a.app} (sinon : proposer l'app cible) ?`, excerpts: [ `Application ${a.app} — ${appDoc.title}${appDoc.contexte ? `\n${appDoc.contexte.slice(0, 400)}` : ''}`, ...modules.map((m) => `${m.module} — ${parseIndexDoc(m.indexRaw).title}`), ], }, ] return { findings: [finding('MENU-001', DIM, 'ok', { app: a.app }, `${modules.length} module(s) à arbitrer sémantiquement contre l'application (jugement requis).`)], judgments, } }, }, { id: 'MENU-002', dimension: DIM, scope: 'project', kind: 'hybrid', evaluate({ model }) { if (model.apps.length < 2) { return { findings: [finding('MENU-002', DIM, 'ok', {}, 'Une seule application — duplication inter-apps sans objet.')] } } const byCode = new Map() for (const m of model.modules) { const code = fold(m.module) if (GENERIC_MODULES.has(code)) continue if (!byCode.has(code)) byCode.set(code, []) byCode.get(code)!.push(m.app) } const exact = [...byCode.entries()].filter(([, apps]) => new Set(apps).size > 1) const judgments: JudgmentItem[] = exact.map(([code, apps]) => ({ ruleId: 'MENU-002', dimension: DIM, scope: {}, maxSeverity: 'warn', question: `Le module « ${code} » présent dans ${[...new Set(apps)].join(' + ')} est-il un VRAI doublon de concept ou une réutilisation légitime par app ?`, excerpts: [...new Set(apps)].map((a) => `${a}/${code.toUpperCase()}`), })) return { findings: [ exact.length > 0 ? finding('MENU-002', DIM, 'warn', {}, 'Code(s) de module identiques dans plusieurs applications — arbitrer vrai doublon vs réutilisation.', exact.map(([code, apps]) => `${code} : ${[...new Set(apps)].join(', ')}`)) : finding('MENU-002', DIM, 'ok', {}, 'Aucun code de module dupliqué entre applications.'), ], judgments, } }, }, { id: 'MENU-003', dimension: DIM, scope: 'app', kind: 'mechanical', evaluate({ model, app }) { const a = app! const errs: string[] = [] const appDoc = parseIndexDoc(a.indexRaw) const builtinApp = matchBuiltinApp(a.app) ?? matchBuiltinApp(appDoc.title) if (builtinApp) { const declaresExtension = EXTENSION_DECLARATION_RE.test(appDoc.contexte) if (!declaresExtension) { errs.push(`application ${a.app} → duplique l'app plateforme « ${builtinApp.code} » (déclarer l'extension dans ## Contexte ou déposer les modules SOUS l'app intégrée)`) } } if (CONFIG_APP_RE.test(a.app) || CONFIG_APP_RE.test(appDoc.title)) { errs.push(`application ${a.app} : la configuration est un MODULE, jamais une application`) } if (TECHNICAL_FEATURES_RE.test(a.app)) { errs.push(`application ${a.app} : feature technique transverse — jamais une application utilisateur`) } for (const m of model.modules.filter((x) => x.app === a.app)) { const hit = matchBuiltinModule(m.module) if (hit) errs.push(`module ${a.app}/${m.module} → recrée le module distinctif « ${hit.module.code} » de l'app plateforme « ${hit.app.code} »`) if (TECHNICAL_FEATURES_RE.test(m.module)) errs.push(`module ${a.app}/${m.module} : feature technique transverse — jamais un module utilisateur`) } return { findings: [ errs.length > 0 ? finding('MENU-003', DIM, 'err', { app: a.app }, 'Duplication d’une application/module plateforme intégrée — ÉTENDRE, jamais recréer.', errs) : finding('MENU-003', DIM, 'ok', { app: a.app }, 'Aucune collision avec le catalogue des apps plateforme.'), ], } }, }, { id: 'MENU-004', dimension: DIM, scope: 'app', kind: 'mechanical', evaluate({ model, app }) { const a = app! const errs: string[] = [] const modules = model.modules.filter((m) => m.app === a.app) if (modules.length === 0) errs.push(`application ${a.app} : aucune application ne devrait être vide (0 module)`) for (const m of modules) { if (m.sections.length === 0) errs.push(`module ${a.app}/${m.module} : 0 section — aucune UI exposable`) } return { findings: [ errs.length > 0 ? finding('MENU-004', DIM, 'err', { app: a.app }, 'App vide / module vide.', errs) : finding('MENU-004', DIM, 'ok', { app: a.app }, `${modules.length} module(s), tous avec ≥ 1 section.`), ], } }, }, { id: 'MENU-005', dimension: DIM, scope: 'app', kind: 'judgment', evaluate({ model, app }) { const a = app! const root = substantiveHorsPerimetre(model.rootIndexRaw) const appHp = substantiveHorsPerimetre(a.indexRaw) const modules = model.modules.filter((m) => m.app === a.app) const anchors = [ ...(root !== '' ? [`racine :\n${root.slice(0, 400)}`] : []), ...(appHp !== '' ? [`app ${a.app} :\n${appHp.slice(0, 400)}`] : []), ] if (anchors.length === 0 && modules.every((m) => substantiveHorsPerimetre(m.indexRaw) === '')) { return { findings: [finding('MENU-005', DIM, 'ok', { app: a.app }, 'Aucun `## Hors-périmètre` déclaré — vérification sans objet.')] } } const judgments: JudgmentItem[] = [ { ruleId: 'MENU-005', dimension: DIM, scope: { app: a.app }, maxSeverity: 'warn', question: `Un module contredit-il SÉMANTIQUEMENT un Hors-périmètre déclaré (un mot-clé partagé sur une fonction différente n'est pas un hit) ?`, excerpts: [...anchors, ...modules.map((m) => `${m.module} — ${parseIndexDoc(m.indexRaw).title}`)], }, ] return { findings: [finding('MENU-005', DIM, 'ok', { app: a.app }, 'Hors-périmètre déclaré — contradiction sémantique à arbitrer (jugement requis).')], judgments, } }, }, { id: 'MENU-006', dimension: DIM, scope: 'app', kind: 'mechanical', evaluate({ model, app }) { const a = app! const collisions: string[] = [] for (const m of model.modules.filter((x) => x.app === a.app)) { if (fold(m.module) === fold(a.app)) collisions.push(`APP=${a.app} / MOD=${m.module}`) for (const s of m.sections) { if (fold(s) === fold(m.module)) collisions.push(`MOD=${m.module} / SEC=${s}`) if (fold(s) === fold(a.app)) collisions.push(`APP=${a.app} / SEC=${m.module}/${s}`) } } return { findings: [ collisions.length > 0 ? finding('MENU-006', DIM, 'warn', { app: a.app }, 'Codes identiques entre niveaux de hiérarchie — la navigation générée ne peut pas se construire (routes en collision).', collisions) : finding('MENU-006', DIM, 'ok', { app: a.app }, 'Chaque niveau porte un code distinct.'), ], } }, }, { id: 'MENU-007', dimension: DIM, scope: 'app', kind: 'mechanical', evaluate({ model, app }) { const a = app! const errs: string[] = [] const warns: string[] = [] const appraise = (label: string, raw: string | null, required: boolean): void => { if (raw === null) return // a missing index.md is MENU-004's finding, not a Contexte defect const ctx = parseIndexDoc(raw).contexte if (ctx === '') { ;(required ? errs : warns).push(`${label} : \`## Contexte\` absent ou vide.`) } else if (required && ctx.length < CONTEXT_MIN_CHARS) { warns.push(`${label} : Contexte de ${ctx.length} caractères (< ${CONTEXT_MIN_CHARS}) — 3-5 phrases attendues (QUI / QUOI / LIMITES).`) } } appraise(`Application ${a.app}`, a.indexRaw, true) for (const m of model.modules.filter((x) => x.app === a.app)) { appraise(`Module ${a.app}/${m.module}`, m.indexRaw, true) for (const s of m.sectionNodes) appraise(`Section ${a.app}/${m.module}/${s.folder}`, s.indexRaw, false) } const findings: Finding[] = [] if (errs.length > 0) { findings.push(finding('MENU-007', DIM, 'err', { app: a.app }, 'Contexte manquant — le PRD, les phases aval et les sources n’ont AUCUN ancrage métier pour ce nœud.', errs)) } if (warns.length > 0) { findings.push(finding('MENU-007', DIM, 'warn', { app: a.app }, 'Contexte trop maigre — un Contexte d’une ligne ne porte ni le QUI, ni le QUOI, ni les LIMITES.', warns)) } if (findings.length === 0) { findings.push(finding('MENU-007', DIM, 'ok', { app: a.app }, 'Chaque nœud requis porte un Contexte substantiel.')) } return { findings } }, }, { id: 'MENU-008', dimension: DIM, scope: 'app', kind: 'mechanical', evaluate({ model, app }) { const a = app! const errs: string[] = [] const warns: string[] = [] const appraise = (label: string, raw: string | null, required: boolean): void => { if (raw === null) return if (!HP_HEADING_RE.test(raw)) { ;(required ? errs : warns).push( `${label} : \`## Hors-périmètre\` ABSENT — l'absence d'exclusion est une AFFIRMATION, elle s'écrit (\`${EMPTY_MARKER}\`).`, ) return } const body = parseIndexDoc(raw).horsPerimetre const hasBullet = /^\s*-\s+\S/m.test(body) const hasEmptyMarker = fold(body).includes(EMPTY_MARKER_FOLDED) if (!hasBullet && !hasEmptyMarker && required) { errs.push(`${label} : \`## Hors-périmètre\` vide — liste les exclusions ou écris le marqueur explicite \`${EMPTY_MARKER}\`.`) } } appraise(`Application ${a.app}`, a.indexRaw, true) for (const m of model.modules.filter((x) => x.app === a.app)) { appraise(`Module ${a.app}/${m.module}`, m.indexRaw, true) for (const s of m.sectionNodes) appraise(`Section ${a.app}/${m.module}/${s.folder}`, s.indexRaw, false) } const findings: Finding[] = [] if (errs.length > 0) { findings.push(finding('MENU-008', DIM, 'err', { app: a.app }, 'Hors-périmètre non déclaré — le PRD ne peut pas cascader les non-goals et MENU-005 n’a rien à confronter.', errs)) } if (warns.length > 0) { findings.push(finding('MENU-008', DIM, 'warn', { app: a.app }, 'Hors-périmètre absent sur des sections — encouragé, non bloquant.', warns)) } if (findings.length === 0) { findings.push(finding('MENU-008', DIM, 'ok', { app: a.app }, 'Chaque app/module déclare son Hors-périmètre (exclusions ou marqueur vide explicite).')) } return { findings } }, }, ] const HP_HEADING_RE = /^##\s+hors[- ]p[ée]rim[èe]tre/im /** The explicit empty form — absence of exclusions as an ASSERTION, not an omission (SSOT: lib/ba-menu-tree.ts). */ export const EMPTY_MARKER = EMPTY_HP_MARKER const EMPTY_MARKER_FOLDED = fold('Aucune exclusion connue') /** Hors-périmètre body with the empty marker treated as « nothing declared » — * MENU-005 has nothing to confront against an assertion of absence. */ function substantiveHorsPerimetre(raw: string | null): string { const hp = parseIndexDoc(raw).horsPerimetre return fold(hp).includes(EMPTY_MARKER_FOLDED) && !/^\s*-\s+\S/m.test(hp) ? '' : hp } export type { Finding }