/** * lib/ba-actors.ts — shared `acteur.md` parser + actor → seeded-role mapping. * * Actors are PROJECT-scoped, authored at the Application level * (`//acteur.md`, doc-templates.md § acteur.md): * * ### BA-001-AC-001 — Commercial * - **Type** : internal * - **Catégorie** : gestionnaire (optional — role-taxonomy.md labels) * * The seeded role derived from an actor follows ONE deterministic rule: * role.code = slugifyRoleCode(actor label) (lib/string-utils.ts) * role.name = actor label, verbatim * so `documentation/extract-doc`'s actor↔role identity (label ⇔ role.name, * case/trim-insensitive — see `actorMatchesRole` in lib/ba-rbac-rows.ts) * holds by construction on regenerated projects, and legacy projects with * hand-picked role codes still match through the name leg. * * The **Catégorie** label maps to the platform RoleCategory enum NAME via the * tolerant table of `business-analyse/_workflow/role-taxonomy.md` — mirrored * here as ROLE_CATEGORY_BY_LABEL and drift-tested against that doc * (lib/__tests__/ba-actors.test.ts). Unknown/absent → undefined (the seed * defaults to Custom); `Global` is platform-reserved and refused with a * warning. * * Consumers: business-analyse/create-rbac/cli/derive-rbac-grants (Phase 0 * RBAC fragment + DEV-CORE-011 parity check). */ import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' /** One parsed actor of an application's `acteur.md`. */ export interface BaActor { /** BA actor code (`BA-{seq}-AC-{NNN}`). */ code: string /** Human label from the heading (`Commercial`, `Manager commercial`). */ label: string /** `internal | external | system` (free text transported verbatim). */ type?: string /** Raw **Catégorie** label as authored (before taxonomy mapping). */ categorie?: string } /** `### BA-001-AC-001 — Commercial` (em-dash or ASCII dash). */ export const ACTOR_HEADING_RE = /^###\s+(BA-[A-Za-z0-9]+-AC-\d+)\s+[—-]\s+(.+?)\s*$/ const FIELD_RE = /^-\s*\*\*([^*]+)\*\*\s*:\s*(.*?)\s*$/ /** * Role-taxonomy label → platform RoleCategory enum NAME (tolerant, mirrors * `_workflow/role-taxonomy.md` — keep both in sync, the drift test compares * them). Keys are NFD-stripped lowercase. */ export const ROLE_CATEGORY_BY_LABEL: Readonly> = { admin: 'Admin', administration: 'Admin', manager: 'Manager', gestion: 'Manager', gestionnaire: 'Manager', contributor: 'Contributor', contribution: 'Contributor', contributeur: 'Contributor', operateur: 'Contributor', viewer: 'Viewer', consultation: 'Viewer', lecteur: 'Viewer', custom: 'Custom', } /** NFD-strip combining marks → lowercase → trim (slugifyRoleCode pipeline). */ export function normalizeCategoryLabel(label: string): string { return label .normalize('NFD') .replace(/[̀-ͯ]/g, '') .toLowerCase() .trim() } /** * Map an authored **Catégorie** label to the RoleCategory enum name. * Absent/unknown → `{}` (seed defaults to Custom); `Global` → refused with a * warning (platform-reserved, role-taxonomy.md). */ export function mapRoleCategory(label?: string): { category?: string; warning?: string } { if (!label || !label.trim()) return {} const normalized = normalizeCategoryLabel(label) if (normalized === 'global') { return { warning: `Catégorie "Global" is platform-reserved (role-taxonomy.md) — ignored, the role seeds as Custom.`, } } const category = ROLE_CATEGORY_BY_LABEL[normalized] if (category === undefined) { return { warning: `Unknown Catégorie label "${label}" — not in role-taxonomy.md; the role seeds as Custom.`, } } return { category } } /** Parse an `acteur.md` content into its actor list. */ export function parseActors(content: string): { actors: BaActor[]; warnings: string[] } { const actors: BaActor[] = [] const warnings: string[] = [] let current: BaActor | null = null for (const line of content.split(/\r?\n/)) { const heading = ACTOR_HEADING_RE.exec(line) if (heading) { current = { code: heading[1], label: heading[2] } actors.push(current) continue } if (!current) continue const field = FIELD_RE.exec(line.trim()) if (!field) continue const key = normalizeCategoryLabel(field[1]) if (key === 'type') current.type = field[2] || undefined else if (key === 'categorie') current.categorie = field[2] || undefined } const seen = new Set() for (const actor of actors) { if (seen.has(actor.code)) { warnings.push(`Duplicate actor code ${actor.code} in acteur.md — later headings shadow earlier ones downstream.`) } seen.add(actor.code) } return { actors, warnings } } /** Load + parse `//acteur.md`. `exists: false` when absent. */ export function loadAppActors( baRoot: string, appFolder: string, ): { exists: boolean; actors: BaActor[]; warnings: string[] } { const path = join(baRoot, appFolder, 'acteur.md') if (!existsSync(path)) return { exists: false, actors: [], warnings: [] } try { const parsed = parseActors(readFileSync(path, 'utf8')) return { exists: true, ...parsed } } catch { return { exists: false, actors: [], warnings: [] } } }