/** * cli:audit-ba — corpus/model.ts * * ONE walk, ONE parse: loads the whole `.smartstack/ba/` tree into memory via * the shared lib parsers, so every rule of every dimension runs over the same * CorpusModel — no re-reads, no per-rule I/O, no spawns. */ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { join } from 'node:path' import { loadAppActors, type BaActor } from '../../../../../lib/ba-actors.js' import { loadModuleEntities, type BaEntity } from '../../../../../lib/ba-entities.js' import { normalizeModulePath } from '../../../../../lib/ba-relations.js' import { TEST_DATA_FILE, loadModuleTestData, type BaTestDataDoc } from '../../../../../lib/ba-test-data.js' import { loadEntityContents, parseEntities, type RelationGraph } from '../../../../../lib/ba-relations.js' import { loadModuleRbacRows, type RbacRow } from '../../../../../lib/ba-rbac-rows.js' import { loadModuleRules, type BaRule } from '../../../../../lib/ba-rules-rows.js' import { loadScreens, type ParsedScreen } from '../../../../../lib/ba-screens.js' import { parseUseCases, type BaUseCase } from '../../../../../lib/ba-use-cases.js' import { canonicalHash } from '../../../../../lib/canonical-hash.js' import { loadSourcesRegistry, sourcesRootFor, type SourcesRegistry } from '../../../../../lib/ba-sources.js' import type { ModuleTotals, ParseTotals, ProjectConventions } from '../types.js' import { detectConventions } from './conventions.js' export type DocKind = 'index' | 'acteur' | 'use-case' | 'regles' | 'rbac' | 'entite' | 'screen' | 'jeu-de-test' export interface CorpusDoc { /** Path relative to baRoot, forward slashes. */ relPath: string kind: DocKind raw: string app?: string module?: string } export interface AppModel { app: string /** App `index.md` raw content (null when absent). */ indexRaw: string | null actorsFileExists: boolean actors: BaActor[] actorWarnings: string[] screens: ParsedScreen[] screenWarnings: string[] } /** Parsed shape of a node `index.md` — title + the scope-bearing sections. */ export interface IndexDoc { title: string contexte: string horsPerimetre: string } export function parseIndexDoc(raw: string | null): IndexDoc { if (raw === null) return { title: '', contexte: '', horsPerimetre: '' } const title = raw.match(/^#\s+(.+?)\s*$/m)?.[1] ?? '' const section = (name: RegExp): string => { const lines = raw.split(/\r?\n/) const start = lines.findIndex((l) => /^##\s+/.test(l) && name.test(l)) if (start === -1) return '' const rest = lines.slice(start + 1) const end = rest.findIndex((l) => /^##\s+/.test(l)) return rest.slice(0, end === -1 ? undefined : end).join('\n').trim() } return { title, contexte: section(/contexte/i), horsPerimetre: section(/hors[- ]p[eé]rim[eè]tre/i), } } export interface UcDoc { /** Relative to the MODULE dir (the shape parseUseCases expects). */ relPath: string ucs: BaUseCase[] warnings: string[] lost: string[] } export interface ModuleModel { app: string module: string dir: string /** Module `index.md` raw content (null when absent). */ indexRaw: string | null /** Section folder names (direct child dirs, `_*`/`.`/pagespecs excluded). */ sections: string[] /** Section nodes with their own `index.md` raw content. */ sectionNodes: { folder: string; indexRaw: string | null }[] ucDocs: UcDoc[] ucs: BaUseCase[] entities: BaEntity[] entityWarnings: string[] entityDocExists: boolean /** The module's business TEST DATASET (`jeu-de-test.md`, lib/ba-test-data) — null when absent (a legitimate state). */ testData: BaTestDataDoc | null testDataExists: boolean rules: BaRule[] ruleWarnings: string[] rbacExists: boolean rbacRows: RbacRow[] rbacContent: string | null screens: ParsedScreen[] } export interface CorpusModel { baRoot: string /** Project-root `index.md` raw content (Hors-périmètre anchor of MENU-005). */ rootIndexRaw: string | null apps: AppModel[] modules: ModuleModel[] /** Project-wide relation graph (cross-module targets, incoming relations). */ graph: RelationGraph docs: CorpusDoc[] totals: ParseTotals perModule: ModuleTotals[] conventions: ProjectConventions /** Client-sources registry (SIBLING root `.smartstack/sources/`) — the * SRC-001..007 rules read it HERE, never the disk. `exists:false` is a * legitimate state (« sans objet »), never an error by itself. */ sources: SourcesRegistry sourcesHash: string /** * Entities + business test dataset of EVERY module of the tree (full-tree, * like `graph`): a `jeu-de-test.md` cites rows of modules OUTSIDE the * requested scope, and DM-030 must resolve them where the relation says * they live. Keyed by NORMALIZED `APP/MOD`. */ testDataUniverse: Map parseWarnings: string[] } /** One module of the test-data universe — the shape lib/ba-test-data-check consumes. */ export interface TestDataModuleInputs { modulePath: string entities: BaEntity[] testData: BaTestDataDoc | null } /** Every `APP/MOD` carrying an entité.md, with its dataset — never scoped. */ function loadTestDataUniverse(baRoot: string): Map { const out = new Map() for (const app of listDirs(baRoot)) { for (const mod of listDirs(join(baRoot, app))) { const entities = loadModuleEntities(baRoot, app, mod) if (entities === null) continue const modulePath = `${app}/${mod}` out.set(normalizeModulePath(modulePath), { modulePath, entities: entities.entities, testData: loadModuleTestData(baRoot, app, mod).doc }) } } return out } const SKIP_DIRS = new Set(['pagespecs', 'node_modules']) function listDirs(dir: string): string[] { let entries try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return [] } return entries .filter((e) => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.') && !SKIP_DIRS.has(e.name)) .map((e) => e.name) .sort() } function readDoc(path: string): string | null { try { if (existsSync(path) && statSync(path).isFile()) return readFileSync(path, 'utf8') } catch { /* unreadable — treated as absent, the audit rules surface the gap */ } return null } /** Both NFC and NFD forms of the accented filename exist on real disks. */ function readAccented(dir: string, nfcName: string): string | null { return readDoc(join(dir, nfcName)) ?? readDoc(join(dir, nfcName.normalize('NFD'))) } export interface LoadScope { app?: string module?: string } export function loadCorpus(baRoot: string, scope: LoadScope = {}): CorpusModel { const parseWarnings: string[] = [] const docs: CorpusDoc[] = [] const apps: AppModel[] = [] const modules: ModuleModel[] = [] const pushDoc = (kind: DocKind, relPath: string, raw: string, app?: string, module?: string): void => { docs.push({ kind, relPath, raw, ...(app !== undefined ? { app } : {}), ...(module !== undefined ? { module } : {}) }) } const appFolders = listDirs(baRoot).filter((a) => !scope.app || a.toLowerCase() === scope.app.toLowerCase()) for (const app of appFolders) { const appDir = join(baRoot, app) const acteurRaw = readDoc(join(appDir, 'acteur.md')) if (acteurRaw !== null) pushDoc('acteur', `${app}/acteur.md`, acteurRaw, app) const actorsLoaded = loadAppActors(baRoot, app) const screensLoaded = loadScreens(baRoot, app) apps.push({ app, indexRaw: readDoc(join(appDir, 'index.md')), actorsFileExists: actorsLoaded.exists, actors: actorsLoaded.actors, actorWarnings: actorsLoaded.warnings, screens: screensLoaded.screens, screenWarnings: screensLoaded.warnings, }) // Every loader's near-misses reach the report, not only the UC ones below — // a warning stored on the model and read by nobody is silence with extra // steps (the entité.md heading near-miss died that way). for (const w of actorsLoaded.warnings) parseWarnings.push(`${app}/acteur.md: ${w}`) for (const w of screensLoaded.warnings) parseWarnings.push(`${app}: ${w}`) const moduleFolders = listDirs(appDir).filter( (m) => !scope.module || m.toLowerCase() === scope.module.toLowerCase(), ) for (const module of moduleFolders) { const moduleDir = join(appDir, module) const sections = listDirs(moduleDir) // use-case.md at module level + per section + per resource (depth ≤ 2 under module). const ucDocs: UcDoc[] = [] const collectUc = (dir: string, rel: string[]): void => { if (rel.length > 2) return const raw = readDoc(join(dir, 'use-case.md')) if (raw !== null) { const relPath = [...rel, 'use-case.md'].join('/') pushDoc('use-case', `${app}/${module}/${relPath}`, raw, app, module) const parsed = parseUseCases(raw, relPath) ucDocs.push({ relPath, ucs: parsed.ucs, warnings: parsed.warnings, lost: parsed.lost }) for (const w of parsed.warnings) parseWarnings.push(`${app}/${module}/${relPath}: ${w}`) } for (const child of listDirs(dir)) collectUc(join(dir, child), [...rel, child]) } collectUc(moduleDir, []) const entiteRaw = readAccented(moduleDir, 'entité.md') if (entiteRaw !== null) pushDoc('entite', `${app}/${module}/entité.md`, entiteRaw, app, module) const entitiesLoaded = loadModuleEntities(baRoot, app, module) // jeu-de-test.md — the business test dataset (optional). Its parse losses // reach the report like entité.md's: a malformed table is never « 0 rows ». const testDataRaw = readDoc(join(moduleDir, TEST_DATA_FILE)) if (testDataRaw !== null) pushDoc('jeu-de-test', `${app}/${module}/${TEST_DATA_FILE}`, testDataRaw, app, module) const testDataLoaded = loadModuleTestData(baRoot, app, module) // règles-métier.md at module level + per section (loadModuleRules aggregates). const collectRules = (dir: string, rel: string[]): void => { if (rel.length > 2) return const raw = readAccented(dir, 'règles-métier.md') if (raw !== null) pushDoc('regles', `${app}/${module}/${[...rel, 'règles-métier.md'].join('/')}`, raw, app, module) for (const child of listDirs(dir)) collectRules(join(dir, child), [...rel, child]) } collectRules(moduleDir, []) const rulesLoaded = loadModuleRules(baRoot, app, module) const rbacRaw = readDoc(join(moduleDir, 'rbac.md')) if (rbacRaw !== null) pushDoc('rbac', `${app}/${module}/rbac.md`, rbacRaw, app, module) const rbacLoaded = loadModuleRbacRows(baRoot, app, module) const collectScreens = (dir: string, rel: string[]): void => { if (rel.length > 2) return const raw = readDoc(join(dir, 'screen.md')) if (raw !== null) pushDoc('screen', `${app}/${module}/${[...rel, 'screen.md'].join('/')}`, raw, app, module) for (const child of listDirs(dir)) collectScreens(join(dir, child), [...rel, child]) } collectScreens(moduleDir, []) modules.push({ app, module, dir: moduleDir, indexRaw: readDoc(join(moduleDir, 'index.md')), sections, sectionNodes: sections.map((s) => ({ folder: s, indexRaw: readDoc(join(moduleDir, s, 'index.md')) })), ucDocs, ucs: ucDocs.flatMap((d) => d.ucs), entities: entitiesLoaded?.entities ?? [], entityWarnings: entitiesLoaded?.warnings ?? [], entityDocExists: entitiesLoaded !== null, testData: testDataLoaded.doc, testDataExists: testDataLoaded.exists, rules: rulesLoaded.rules, ruleWarnings: rulesLoaded.warnings, rbacExists: rbacLoaded.exists, rbacRows: rbacLoaded.rows, rbacContent: rbacRaw, screens: screensLoaded.screens.filter((s) => s.module.toLowerCase() === module.toLowerCase()), }) const modulePrefix = `${app}/${module}: ` const unprefixed = (w: string): string => (w.startsWith(modulePrefix) ? w.slice(modulePrefix.length) : w) for (const w of entitiesLoaded?.warnings ?? []) parseWarnings.push(`${app}/${module}/entité.md: ${unprefixed(w)}`) for (const w of testDataLoaded.doc?.warnings ?? []) parseWarnings.push(`${app}/${module}/${TEST_DATA_FILE}: ${unprefixed(w)}`) for (const w of rulesLoaded.warnings) parseWarnings.push(`${app}/${module}/règles-métier.md: ${unprefixed(w)}`) } } // Project-wide relation graph (always full-tree — cross-module targets may // live outside the requested scope). const graph = parseEntities(loadEntityContents(baRoot)) const testDataUniverse = loadTestDataUniverse(baRoot) const perModule: ModuleTotals[] = modules.map((m) => ({ app: m.app, module: m.module, sections: m.sections.length, ucs: m.ucs.length, acs: m.ucs.reduce((n, u) => n + u.acs.length, 0), rules: m.rules.length, entities: m.entities.length, screens: m.screens.length, rbacRows: m.rbacRows.length, })) const allRules = modules.flatMap((m) => m.rules) const errorCodes = allRules.map((r) => r.errorCode).filter((c): c is string => c !== undefined && c !== '') const totals: ParseTotals = { apps: apps.length, modules: modules.length, sections: perModule.reduce((n, m) => n + m.sections, 0), actors: apps.reduce((n, a) => n + a.actors.length, 0), ucs: perModule.reduce((n, m) => n + m.ucs, 0), acs: perModule.reduce((n, m) => n + m.acs, 0), rules: perModule.reduce((n, m) => n + m.rules, 0), errorCodes: errorCodes.length, distinctErrorCodes: new Set(errorCodes).size, entities: perModule.reduce((n, m) => n + m.entities, 0), attributes: modules.reduce((n, m) => n + m.entities.reduce((k, e) => k + e.attributes.length, 0), 0), screens: perModule.reduce((n, m) => n + m.screens, 0), rbacRows: perModule.reduce((n, m) => n + m.rbacRows, 0), } const conventions = detectConventions(modules) const sources = loadSourcesRegistry(sourcesRootFor(baRoot)) // The freshness stamp covers the sources registry too — a verdict written // before a source changed must read as stale. const sourcesHash = canonicalHash([ ...docs.map((d) => ({ p: d.relPath, h: canonicalHash(d.raw) })), ...sources.docs.map((d) => ({ p: `../sources/${d.relPath}`, h: canonicalHash(d.raw) })), ]) return { baRoot, rootIndexRaw: readDoc(join(baRoot, 'index.md')), apps, modules, graph, docs, totals, perModule, conventions, sources, testDataUniverse, sourcesHash, parseWarnings, } }