/** * cli:derive-change-impact — corpus.ts * * ONE loader of everything the change engine reads, parsed ONCE with the * shared lib parsers (never a private grammar): the actors of every * application, the module's use cases / rules / RBAC rows / entities / * screens / pagespecs, the section anchors (`previousCodes=` aliases written * by /ba-reconcile-menu), the other applications' use cases and rules (the * cross-application look-alike check), and the state probes. * * Accented file names are matched NFC/NFD-insensitively (`règles-métier.md`, * `entité.md` exist under both normalizations on real disks); a doc found * under the non-NFC name is read AND warned about — never skipped silently. */ import { createHash } from 'node:crypto' import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { loadAppActors, type BaActor } from '../../../../lib/ba-actors.js' import { loadModuleEntities, type ParseEntityDocResult } from '../../../../lib/ba-entities.js' import { parseRbacRows, type RbacRow } from '../../../../lib/ba-rbac-rows.js' import { parseRules, type BaRule } from '../../../../lib/ba-rules-rows.js' import { loadScreens, type ParsedScreen } from '../../../../lib/ba-screens.js' import { SOURCES_INDEX_FILE, sourcesRootFor } from '../../../../lib/ba-sources.js' import { parseUseCases, type ParseUseCasesResult } from '../../../../lib/ba-use-cases.js' import type { ChangeState, ResolvedScope } from './types.js' /** Mirror of `create-screen/cli/derive-uc-coverage/execute.ts` JSON_BLOCK_RE — * not importable across skills (the installer only re-points create-rbac / * create-screen relative imports). The FIRST fenced json block is the machine block. */ export const JSON_BLOCK_RE = /```json\s*\r?\n([\s\S]*?)\r?\n```/ export interface CorpusDoc { /** Relative to the module dir, forward slashes (`opportunites/use-case.md`). */ relPath: string absPath: string text: string /** Folder owning the file — `''` for a module-level doc. */ sectionFolder: string /** Depth under the module: 0 module, 1 section, 2 resource. */ depth: number } export interface UseCaseDoc extends CorpusDoc { parsed: ParseUseCasesResult } export interface RulesDoc extends CorpusDoc { rules: BaRule[] warnings: string[] } export interface PagespecDoc { name: string text: string block: Record | null } export interface SectionAnchor { folder: string previousCodes: string[] } export interface AppActors { app: string exists: boolean actors: BaActor[] /** Raw `acteur.md` text ('' when absent) — near-miss headings, machine-block hash. */ text: string } export interface CrossAppUseCases { app: string ucs: Array<{ code: string; title: string; file: string }> } export interface CrossAppRules { app: string rules: Array<{ code: string; title: string; file: string }> } export interface ScopeCorpus { scope: ResolvedScope moduleDir: string /** Every application folder under baRoot (sorted). */ apps: string[] actors: AppActors[] useCaseDocs: UseCaseDoc[] rulesDocs: RulesDoc[] /** Aggregated rules of the module (every doc, deepest scope included). */ rules: BaRule[] rbac: { exists: boolean; relPath: string; text: string; rows: RbacRow[] } entities: ParseEntityDocResult | null entityDoc: CorpusDoc | null screenDocs: CorpusDoc[] /** Screens of THIS module (parsed by lib/ba-screens). */ screens: ParsedScreen[] /** Screens of the whole application (a surface may live in a sibling module). */ appScreens: ParsedScreen[] pagespecs: PagespecDoc[] | null sectionAnchors: SectionAnchor[] /** Section folders directly under the module (sorted). */ sectionFolders: string[] crossAppUseCases: CrossAppUseCases[] crossAppRules: CrossAppRules[] state: ChangeState warnings: string[] } const SKIP_DIR = (name: string): boolean => name.startsWith('_') || name.startsWith('.') || name === 'pagespecs' || name === 'node_modules' /** Find `fileName` in `dir` NFC/NFD-insensitively. Returns the on-disk name or null. */ export function findFileNormalized(dir: string, fileName: string): { name: string; nfc: boolean } | null { let entries: string[] try { entries = readdirSync(dir) } catch { return null } const wanted = fileName.normalize('NFC') for (const name of entries) { if (name.normalize('NFC') === wanted) { try { if (statSync(join(dir, name)).isFile()) return { name, nfc: name === wanted } } catch { /* unreadable entry — keep looking */ } } } return null } /** Walk `rootDir` (depth ≤ maxDepth) and collect every `fileName` — `_`/`.` * folders, `pagespecs` and `node_modules` skipped, every other folder * visited (uppercase/digit/accented names included). */ export function collectDocs( rootDir: string, fileName: string, maxDepth: number, warnings: string[], ): CorpusDoc[] { const out: CorpusDoc[] = [] const walk = (dir: string, rel: string[], depth: number): void => { if (depth > maxDepth) return const hit = findFileNormalized(dir, fileName) if (hit) { const absPath = join(dir, hit.name) const relPath = [...rel, fileName].join('/') try { out.push({ relPath, absPath, text: readFileSync(absPath, 'utf8'), sectionFolder: rel[rel.length - 1] ?? '', depth, }) if (!hit.nfc) { warnings.push(`${relPath}: file name is not NFC-normalized on disk (read anyway — rename it to the NFC form).`) } } catch (err) { warnings.push(`${relPath}: unreadable (${err instanceof Error ? err.message : String(err)}) — INVISIBLE to this run.`) } } let entries try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return } for (const e of entries.filter((x) => x.isDirectory() && !SKIP_DIR(x.name)).sort((a, b) => a.name.localeCompare(b.name))) { walk(join(dir, e.name), [...rel, e.name], depth + 1) } } walk(rootDir, [], 0) return out.sort((a, b) => a.relPath.localeCompare(b.relPath)) } /** `` of a section's index.md. */ function readSectionAnchor(sectionDir: string, folder: string): SectionAnchor { const indexPath = join(sectionDir, 'index.md') if (!existsSync(indexPath)) return { folder, previousCodes: [] } try { const text = readFileSync(indexPath, 'utf8') const anchor = //.exec(text) const prev = anchor?.[1]?.match(/\bpreviousCodes=([^\s]+)/)?.[1] return { folder, previousCodes: prev ? prev.split(',').map((c) => c.trim()).filter(Boolean) : [] } } catch { return { folder, previousCodes: [] } } } function listDirs(dir: string): string[] { try { return readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isDirectory() && !SKIP_DIR(e.name)) .map((e) => e.name) .sort((a, b) => a.localeCompare(b)) } catch { return [] } } function loadPagespecs(moduleDir: string, warnings: string[]): PagespecDoc[] | null { const dir = join(moduleDir, 'pagespecs') if (!existsSync(dir)) return null const out: PagespecDoc[] = [] let names: string[] try { names = readdirSync(dir).filter((n) => n.endsWith('.md')).sort() } catch { return null } for (const name of names) { const full = join(dir, name) try { if (!statSync(full).isFile()) continue const text = readFileSync(full, 'utf8') const m = JSON_BLOCK_RE.exec(text) let block: Record | null = null if (m) { try { const parsed: unknown = JSON.parse(m[1]!) block = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : null } catch { warnings.push(`pagespecs/${name}: machine block is not valid JSON — its links are INVISIBLE to this run.`) } } out.push({ name, text, block }) } catch { warnings.push(`pagespecs/${name}: unreadable — skipped.`) } } return out } /** sha256 of every ` … ` span — `none` when absent. */ export function machineBlocksHashOf(text: string): string { const spans: string[] = [] const re = /[\s\S]*?/g let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) spans.push(m[0]) if (spans.length === 0) return 'none' return createHash('sha256').update(spans.join('\n')).digest('hex') } function readDocIfExists(dir: string, fileName: string, relPath: string, warnings: string[]): CorpusDoc | null { const hit = findFileNormalized(dir, fileName) if (!hit) return null try { const text = readFileSync(join(dir, hit.name), 'utf8') if (!hit.nfc) warnings.push(`${relPath}: file name is not NFC-normalized on disk (read anyway).`) return { relPath, absPath: join(dir, hit.name), text, sectionFolder: '', depth: 0 } } catch (err) { warnings.push(`${relPath}: unreadable (${err instanceof Error ? err.message : String(err)}).`) return null } } /** Load everything the change engine reads for the resolved scope. */ export function loadScopeCorpus(scope: ResolvedScope, projectPath?: string): ScopeCorpus { const warnings: string[] = [] const { baRoot, app, module } = scope const moduleDir = join(baRoot, app, module) const apps = listDirs(baRoot) const actors: AppActors[] = apps.map((a) => { const loaded = loadAppActors(baRoot, a) warnings.push(...loaded.warnings.map((w) => `${a}/acteur.md: ${w}`)) let text = '' if (loaded.exists) { try { text = readFileSync(join(baRoot, a, 'acteur.md'), 'utf8') } catch { /* loadAppActors already read it — an unreadable re-read only loses the raw text */ } } return { app: a, exists: loaded.exists, actors: loaded.actors, text } }) const useCaseDocs: UseCaseDoc[] = collectDocs(moduleDir, 'use-case.md', 3, warnings).map((d) => { const parsed = parseUseCases(d.text, d.relPath) return { ...d, parsed } }) const rulesDocs: RulesDoc[] = collectDocs(moduleDir, 'règles-métier.md', 3, warnings).map((d) => { const parsed = parseRules(d.text) for (const r of parsed.rules) r.docPath = d.relPath return { ...d, rules: parsed.rules, warnings: parsed.warnings.map((w) => `${d.relPath}: ${w}`) } }) const rules = rulesDocs.flatMap((d) => d.rules) for (const d of rulesDocs) warnings.push(...d.warnings) const rbacDoc = readDocIfExists(moduleDir, 'rbac.md', 'rbac.md', warnings) const rbac = rbacDoc ? { exists: true, relPath: 'rbac.md', text: rbacDoc.text, rows: parseRbacRows(rbacDoc.text) } : { exists: false, relPath: 'rbac.md', text: '', rows: [] } const entityDoc = readDocIfExists(moduleDir, 'entité.md', 'entité.md', warnings) const entities = loadModuleEntities(baRoot, app, module) if (entities) warnings.push(...entities.warnings.map((w) => `entité.md: ${w}`)) const screenDocs = collectDocs(moduleDir, 'screen.md', 3, warnings) const appScreensLoaded = loadScreens(baRoot, app) warnings.push(...appScreensLoaded.warnings) const appScreens = appScreensLoaded.screens const screens = appScreens.filter((s) => s.module.toLowerCase() === module.toLowerCase()) const pagespecs = loadPagespecs(moduleDir, warnings) const sectionFolders = listDirs(moduleDir) const sectionAnchors = sectionFolders.map((f) => readSectionAnchor(join(moduleDir, f), f)) // Cross-application look-alikes: titles only, read-only, never written. const crossAppUseCases: CrossAppUseCases[] = [] const crossAppRules: CrossAppRules[] = [] for (const other of apps.filter((a) => a.toLowerCase() !== app.toLowerCase())) { const otherDir = join(baRoot, other) const ucDocs = collectDocs(otherDir, 'use-case.md', 4, warnings) const ucs = ucDocs.flatMap((d) => parseUseCases(d.text, d.relPath).ucs.map((u) => ({ code: u.ucCode, title: u.title, file: `${other}/${d.relPath}` })), ) const ruleDocs = collectDocs(otherDir, 'règles-métier.md', 4, warnings) const otherRules = ruleDocs.flatMap((d) => parseRules(d.text).rules.map((r) => ({ code: r.code, title: r.title, file: `${other}/${d.relPath}` })), ) if (ucs.length > 0) crossAppUseCases.push({ app: other, ucs }) if (otherRules.length > 0) crossAppRules.push({ app: other, rules: otherRules }) } const snapshotPath = join(moduleDir, '.run-snapshot.json') const snapshotFound = existsSync(snapshotPath) const seedStateFound = projectPath !== undefined && existsSync(join(projectPath, '.smartstack', 'core-seed')) const state: ChangeState = { pagespecsPresent: pagespecs !== null && pagespecs.length > 0, prdPresent: existsSync(join(moduleDir, 'prd.md')), developed: snapshotFound || seedStateFound, ...(snapshotFound ? { snapshotPath } : {}), sourcesPresent: existsSync(join(sourcesRootFor(baRoot), SOURCES_INDEX_FILE)), } return { scope, moduleDir, apps, actors, useCaseDocs, rulesDocs, rules, rbac, entities, entityDoc, screenDocs, screens, appScreens, pagespecs, sectionAnchors, sectionFolders, crossAppUseCases, crossAppRules, state, warnings, } } /** The use-case doc owned by a section folder (depth 1), if any. */ export function sectionUseCaseDoc(corpus: ScopeCorpus, sectionFolder: string): UseCaseDoc | undefined { const wanted = sectionFolder.toLowerCase() return corpus.useCaseDocs.find((d) => d.depth === 1 && d.sectionFolder.toLowerCase() === wanted) } /** The rules doc at a given depth-0 (module) or depth-1 (section) scope, if any. */ export function rulesDocAt(corpus: ScopeCorpus, sectionFolder?: string): RulesDoc | undefined { if (!sectionFolder) return corpus.rulesDocs.find((d) => d.depth === 0) const wanted = sectionFolder.toLowerCase() return corpus.rulesDocs.find((d) => d.depth === 1 && d.sectionFolder.toLowerCase() === wanted) } /** * The rules doc that OWNS a rule pinned on `sectionFolder`: the section's own * doc when it exists, else the module doc (`_workflow/ba-files.md` — a rule * lives at its DEEPEST scope, and the deepest EXISTING scope is the owner; a * missing section doc is not a first pass). `fallback` says which one won. */ export function ownerRulesDoc(corpus: ScopeCorpus, sectionFolder?: string): { doc?: RulesDoc; fallback: boolean } { const own = rulesDocAt(corpus, sectionFolder) if (own || !sectionFolder) return { doc: own, fallback: false } const moduleDoc = rulesDocAt(corpus, undefined) return { doc: moduleDoc, fallback: moduleDoc !== undefined } } /** Every module doc text (use cases, rules, screens, entities, rbac) — the body-cite scan surface. */ export function allModuleDocs(corpus: ScopeCorpus): Array<{ relPath: string; text: string }> { const docs: Array<{ relPath: string; text: string }> = [] for (const d of corpus.useCaseDocs) docs.push({ relPath: d.relPath, text: d.text }) for (const d of corpus.rulesDocs) docs.push({ relPath: d.relPath, text: d.text }) for (const d of corpus.screenDocs) docs.push({ relPath: d.relPath, text: d.text }) if (corpus.entityDoc) docs.push({ relPath: corpus.entityDoc.relPath, text: corpus.entityDoc.text }) if (corpus.rbac.exists) docs.push({ relPath: corpus.rbac.relPath, text: corpus.rbac.text }) return docs }