/** * lib/domain-scan.ts — THE scan of a generated app's Domain classes, with their OWNER. * * `scaffold-entity` writes every entity under `src/.Domain/Entities///` * (`lib/app-classification.domainEntitiesDir`) — the folder IS the act of ownership. The * audit-ba code rules used to index classes by bare name and had no notion of who owned * one: a module re-audited after its own `/ba-develop` run saw its own generated classes * as "code that already exists" and CODE-001 warned forever (client NOTES/MEMOS, * 2026-10-03). The doctrine (Simon, 03.10.2026) is at the APPLICATION grain: inside an * application the modules share its entities (one declaring module, the others reference * it); the duplicate check is against the REST of the project — the other applications. * * Lives in `lib/` because two readers share it across the installer's enumerated import * allowlist: audit-ba `rules/code.ts` (CODE-001/002/003/004/007/008) and * `create-data-model/cli/derive-extension-request`. Imports nothing outside `lib/`. * * Determinism: the walk is SORTED (readdir order is host-dependent — `lib/fs` rule), * every file of a class name is kept (`owners`), and the historical single-file maps * (`classes` / `classSources` = the FIRST file in sorted order) keep their meaning. */ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { join, relative } from 'node:path' import { fold } from './ba-referential-codes.js' export interface DomainClassOwner { /** Path relative to projectRoot, forward slashes. */ file: string /** Raw C# source of that file. */ source: string /** `Entities//…` first folder segment — undefined when the file is not under an * `Entities///` folder (flat legacy layout, or outside `Entities/`). */ app?: string /** `Entities///…` second folder segment. */ module?: string } export interface DomainScan { /** class name → FIRST file (sorted order, relative to projectRoot). */ classes: Map /** ToTable("prefix…") prefixes → file. */ tablePrefixes: Map /** Raw content by class name (first file) — the person-link leg of CODE-007. */ classSources: Map /** class name → EVERY file declaring it, with its owner — sorted by file. */ owners: Map filesScanned: number } const scanCache = new Map() /** Tests build several temp projects in one process — the cache must be droppable. */ export function resetDomainScanCache(): void { scanCache.clear() } /** * The owner segments of a Domain file: `…/Entities///.cs` → * `{ app, module }`; a file directly under `Entities/` (flat legacy layout) or outside * `Entities/` has no owner. Same derivation as audit-dev-domain's `scopeSegments`. */ export function ownerOfFile(relFile: string): { app?: string; module?: string } { const normalized = relFile.replace(/\\/g, '/') const after = normalized.split('/Entities/')[1] if (after === undefined) return {} const segments = after.split('/').slice(0, -1) return { ...(segments[0] !== undefined ? { app: segments[0] } : {}), ...(segments[1] !== undefined ? { module: segments[1] } : {}), } } export function scanDomain(projectRoot: string): DomainScan { const cached = scanCache.get(projectRoot) if (cached) return cached const scan: DomainScan = { classes: new Map(), tablePrefixes: new Map(), classSources: new Map(), owners: new Map(), filesScanned: 0 } const roots: string[] = [] const srcDir = join(projectRoot, 'src') try { for (const e of readdirSync(srcDir, { withFileTypes: true })) { if (e.isDirectory() && /\.Domain$/i.test(e.name)) roots.push(join(srcDir, e.name)) } } catch { /* no src/ — greenfield */ } roots.sort() const walk = (dir: string): void => { let entries try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return } entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) for (const e of entries) { if (e.name === 'bin' || e.name === 'obj' || e.name === 'node_modules') continue const p = join(dir, e.name) if (e.isDirectory()) walk(p) else if (e.name.endsWith('.cs')) { let content: string try { content = readFileSync(p, 'utf8') } catch { continue } scan.filesScanned++ const rel = relative(projectRoot, p).replace(/\\/g, '/') const owner = ownerOfFile(rel) for (const m of content.matchAll(/\bclass\s+([A-Za-z_][A-Za-z0-9_]*)/g)) { const name = m[1]! if (!scan.classes.has(name)) { scan.classes.set(name, rel) scan.classSources.set(name, content) } const list = scan.owners.get(name) ?? [] if (!list.some((o) => o.file === rel)) list.push({ file: rel, source: content, ...owner }) scan.owners.set(name, list) } for (const m of content.matchAll(/ToTable\("([a-z]{2,5}_)/g)) { if (!scan.tablePrefixes.has(m[1]!)) scan.tablePrefixes.set(m[1]!, rel) } } } } for (const r of roots) walk(r) scanCache.set(projectRoot, scan) return scan } /** `null` when no usable projectRoot was given — the caller says "code not scanned", never "greenfield". */ export function domainOf(projectRoot: string | undefined): DomainScan | null { if (!projectRoot || !existsSync(projectRoot)) return null try { if (!statSync(projectRoot).isDirectory()) return null } catch { return null } return scanDomain(projectRoot) } // --------------------------------------------------------------------------- // Segment comparison // --------------------------------------------------------------------------- /** * Letters and digits only, accent-stripped, lowercase. A BA folder code is * `GESTION_FLOTTE`; the generated folder segment is `GestionFlotte` * (`appSegment` = `toPascalCase` of the kebab code the orchestrator passes). A plain * accent/case fold keeps the `_` and never matches them — every multi-word application * would see its own classes as another application's. */ export function foldAlnum(s: string): string { return fold(s).replace(/[^a-z0-9]/g, '') } /** True when two segments name the same App/Module whatever their spelling (`GESTION_FLOTTE` ≡ `GestionFlotte` ≡ `gestion-flotte`). */ export function sameSegment(a: string, b: string): boolean { const fa = foldAlnum(a) return fa.length > 0 && fa === foldAlnum(b) } // --------------------------------------------------------------------------- // C# properties // --------------------------------------------------------------------------- /** Provided by the generated base class + tenancy — never an authored attribute (DM-027 is the BA-side twin). */ export const CS_FRAMEWORK_PROPS: ReadonlySet = new Set(['id', 'createdat', 'updatedat', 'deletedat', 'tenantid']) /** A `public class X {` / `public record X {` line is a TYPE declaration, not a property. */ const CS_TYPE_DECL_RE = /^public\s+(?:sealed\s+|abstract\s+|partial\s+|static\s+|readonly\s+)*(?:class|record|struct|interface|enum)\b/ /** FOLDED names of every `public {` property of a class source (framework props included — filter with `CS_FRAMEWORK_PROPS`). */ export function csPropertyNames(source: string): Set { return new Set( [...source.matchAll(/public\s+(?:virtual\s+|required\s+)?[A-Za-z_][A-Za-z0-9_<>?,\[\] ]*?\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{/g)] .filter((m) => !CS_TYPE_DECL_RE.test(m[0])) .map((m) => fold(m[1]!)), ) } /** * The attributes a BA entity declares that the class does NOT carry — what a * requesting module would be asking the owner to add. Framework columns are never * "extra". */ export function extraAttributes(attributeNames: readonly string[], classSource: string): string[] { const have = csPropertyNames(classSource) return attributeNames.filter((n) => { const f = fold(n) return !have.has(f) && !CS_FRAMEWORK_PROPS.has(f) }) }