/** * cli:scaffold-extension-search — discover.ts * * DISCOVERY for `ss upgrade`: derive an ExtensionSearchSpec from an EXISTING * generated project, using only DETERMINISTIC code sources (the BA tree is not * reliable — it can be stale or for a different app). Sources: * * • permissions seed (`*CorePermissionsSeedDataProvider.cs`) — the spine: * authoritative `(path, action, section)` tuples → app/module/section + * which sections carry `.assign/.approve/.reject` (row-scope SUSPECT). * • navigation seed (`*CoreNavigationSeedDataProvider.cs`) — section label / icon / SPA route. * • ExtensionsDbContext + Domain — the client entities (FQN + ITenantEntity). * • screen controllers — the entity ↔ section binding (a list `[HttpGet]` * action with `[RequirePermission(XxxPermissions.Yyy.Read)]` returning `List`). * * SECURITY: row-scope (e.g. BR-010 "a collaborator sees only their own rows") * lives in the handler, NOT in any declarative metadata — it cannot be derived. * So an entity whose section is row-scope-suspect is NOT auto-registered; it is * returned as a REVIEW item (fail-closed: search must never reveal a row the * list hides). The deterministic, non-suspect entities are registered. */ import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import type { ExtensionSearchSpec, SearchEntity } from './types.js'; // ─── Pure parsers (operate on source strings → unit-testable) ────────────── export interface ParsedPermission { path: string; app: string; module: string; section: string; action: string; } /** Parse the `permissionsToSeed` tuples `("app.module.section.action", "action", "section")`. */ export function parsePermissionSeed(src: string): ParsedPermission[] { const out: ParsedPermission[] = []; const re = /\(\s*"([a-z][a-z0-9.\-]*)"\s*,\s*"([a-z]+)"\s*,\s*"([a-z0-9\-]+)"\s*\)/g; let m: RegExpExecArray | null; while ((m = re.exec(src)) !== null) { const path = m[1]; const parts = path.split('.'); if (parts.length !== 4) continue; out.push({ path, app: parts[0], module: parts[1], section: parts[2], action: parts[3] }); } return out; } export interface ParsedSection { code: string; app: string; module: string; label: string; icon: string; route: string; } /** Parse `NavigationSection.Create(... code:"liste" label:"Tâches" icon:"ListTodo" route:"/todo/taches/liste" ...)`. */ export function parseNavSections(src: string): ParsedSection[] { const out: ParsedSection[] = []; const blockRe = /NavigationSection\.Create\(([\s\S]*?)\)\s*;/g; let block: RegExpExecArray | null; while ((block = blockRe.exec(src)) !== null) { const body = block[1]; const code = pick(body, 'code'); const route = pick(body, 'route'); if (!code || !route) continue; const seg = route.split('/').filter(Boolean); // ["todo","taches","liste"] const rawIcon = pick(body, 'icon'); out.push({ code, route, app: seg[0] ?? '', module: seg.length >= 3 ? seg[1] : '', label: pick(body, 'label') ?? code, // The generator emits the literal string "undefined" when the BA left the section icon blank. icon: rawIcon && rawIcon !== 'undefined' ? rawIcon : 'Search', }); } return out; } function pick(body: string, name: string): string | undefined { const m = new RegExp(`${name}\\s*:\\s*"([^"]*)"`).exec(body); return m ? m[1] : undefined; } /** Resolve `using Alias = Full.Qualified.Name;` aliases. */ export function resolveAliases(src: string): Map { const map = new Map(); const re = /using\s+(\w+)\s*=\s*([\w.]+)\s*;/g; let m: RegExpExecArray | null; while ((m = re.exec(src)) !== null) map.set(m[1], m[2]); return map; } /** Parse the client DbSet entity type names from `ExtensionsDbContext.cs` (aliases resolved to FQN where known). */ export function parseDbSetEntities(dbContextSrc: string): { simpleName: string; fqn?: string }[] { const aliases = resolveAliases(dbContextSrc); const seen = new Set(); const out: { simpleName: string; fqn?: string }[] = []; const re = /DbSet<(\w+)>/g; let m: RegExpExecArray | null; while ((m = re.exec(dbContextSrc)) !== null) { const typeRef = m[1]; if (seen.has(typeRef)) continue; seen.add(typeRef); const fqn = aliases.get(typeRef); const simpleName = fqn ? fqn.split('.').pop()! : typeRef; out.push({ simpleName, fqn }); } return out; } /** * From a Domain entity `.cs`: its namespace + whether it is tenant-scoped. * * Tenant-scoped = `ITenantEntity` (Guid TenantId) OR `IOptionalTenantEntity` * (Guid? TenantId) on the declaration, OR a declared `TenantId` property in * the class body (base-class inheritance net — the base list only shows what * is written literally on the declaration). The historical `ITenantEntity`-only * test returned `tenantScoped: false` on every `IOptionalTenantEntity` entity * — i.e. a generated global search that TRAVERSED tenants, exactly the leak * the engine's no-auto-scan rule exists to prevent. */ export function parseDomainEntity(src: string, simpleName: string): { fqn?: string; tenantScoped: boolean } | null { // File-scoped (`namespace X;`) and block-scoped (`namespace X {`) both match. const ns = /namespace\s+([\w.]+)/.exec(src)?.[1]; const classRe = new RegExp(`class\\s+${simpleName}\\b([^{]*)`); const decl = classRe.exec(src); if (!decl) return null; const tenantScoped = /\b(ITenantEntity|IOptionalTenantEntity)\b/.test(decl[1]) || /\bpublic\s+Guid\??\s+TenantId\b/.test(src); return { fqn: ns ? `${ns}.${simpleName}` : undefined, tenantScoped }; } /** * Parse client entity type names from `IEntityTypeConfiguration` classes. * The `ss init` ExtensionsDbContext template declares NO `DbSet<>` (the model * is mounted by `ApplyConfigurationsFromAssembly`), so DbSet parsing alone * finds NOTHING on a standard project — silently. Configurations are the * reliable enumeration: scaffold-entity emits one per entity. */ export function parseConfigurationEntities(src: string): { simpleName: string; fqn?: string }[] { const out: { simpleName: string; fqn?: string }[] = []; const seen = new Set(); const re = /IEntityTypeConfiguration<([\w.]+)>/g; let m: RegExpExecArray | null; while ((m = re.exec(src)) !== null) { const typeRef = m[1]; if (seen.has(typeRef)) continue; seen.add(typeRef); const simpleName = typeRef.split('.').pop()!; out.push({ simpleName, fqn: typeRef.includes('.') ? typeRef : undefined }); } return out; } /** * Map a permission constant reference (`ParametresPermissions.Priorities.Read`) to its literal value * (`"parametres.priorites.read"`). The VALUE carries the real (French/i18n) section code even when the * nested class name is the English plural — the class-name heuristic would mis-derive it. */ export function parsePermissionConstants(src: string): Map { const map = new Map(); const outer = /public\s+static\s+class\s+(\w+Permissions)\b/.exec(src)?.[1]; if (!outer) return map; // Nested static classes (whose name does NOT end in "Permissions" → excludes the outer itself). const nestedRe = /public\s+static\s+class\s+(?!\w*Permissions\b)(\w+)\s*\{([\s\S]*?)\}/g; let n: RegExpExecArray | null; while ((n = nestedRe.exec(src)) !== null) { const memberRe = /public\s+const\s+string\s+(\w+)\s*=\s*"([^"]+)"/g; let mem: RegExpExecArray | null; while ((mem = memberRe.exec(n[2])) !== null) { map.set(`${outer}.${n[1]}.${mem[1]}`, mem[2]); } } return map; } export interface ParsedListScreen { entitySimpleName: string; module: string; section: string; } /** * Parse a screen controller for its LIST action — a parameterless `[HttpGet]` * gated by `[RequirePermission(XxxPermissions.Yyy.Read)]` returning a collection * of `ZDto`. Yields the (entity Z, module, section) binding. When a * constMap is supplied, the real module/section come from * the constant's VALUE (handles i18n section codes); otherwise they fall back to * the kebab-cased class names. */ export function parseListControllers(src: string, constMap?: Map): ParsedListScreen[] { const out: ParsedListScreen[] = []; const re = /\[HttpGet\]\s*(?:\r?\n\s*\[[^\]]*\]\s*)*?\[RequirePermission\(\s*((\w+)Permissions\.(\w+)\.\w+)\s*\)\][\s\S]{0,500}?typeof\(\s*(?:List|IReadOnlyList|IEnumerable|ICollection|IList)<(\w+)Dto>/g; let m: RegExpExecArray | null; while ((m = re.exec(src)) !== null) { const fullRef = m[1]; let module = pascalToKebab(m[2]); let section = pascalToKebab(m[3]); const value = constMap?.get(fullRef); if (value) { const parts = value.split('.'); // "[app.]module.section.action" if (parts.length >= 3) { section = parts[parts.length - 2]; module = parts[parts.length - 3]; } } out.push({ module, section, entitySimpleName: m[4] }); } return out; } function pascalToKebab(s: string): string { return s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); } const ROW_SCOPE_ACTIONS = new Set(['assign', 'approve', 'reject']); // ─── Assembly ────────────────────────────────────────────────────────────── export interface ReviewItem { kind: 'row-scope-suspect' | 'unbound-entity' | 'unresolved-section'; entity?: string; section?: string; detail: string; /** A ready-to-paste (commented) registration line for the human to complete. */ suggestion?: string; } export interface DiscoveryResult { spec: ExtensionSearchSpec; review: ReviewItem[]; } export interface AssembleInput { appCode: string; projectPath: string; contextType: string; permissions: ParsedPermission[]; sections: ParsedSection[]; entities: { simpleName: string; fqn?: string; tenantScoped: boolean }[]; listScreens: ParsedListScreen[]; } export function assemble(input: AssembleInput): DiscoveryResult { const review: ReviewItem[] = []; const registered: SearchEntity[] = []; // Index helpers. const sectionByKey = new Map(input.sections.map((s) => [`${s.module}/${s.code}`, s])); const entityByName = new Map(input.entities.map((e) => [e.simpleName, e])); const readPathBySection = new Map(); const rowScopeSuspect = new Set(); // "module/section" for (const p of input.permissions) { const key = `${p.module}/${p.section}`; if (p.action === 'read') readPathBySection.set(key, p.path); if (ROW_SCOPE_ACTIONS.has(p.action)) rowScopeSuspect.add(key); } const boundEntities = new Set(); for (const screen of input.listScreens) { const key = `${screen.module}/${screen.section}`; const section = sectionByKey.get(key); const readPath = readPathBySection.get(key); const entity = entityByName.get(screen.entitySimpleName); if (!entity) continue; // not a client extension entity (Core/other) — skip silently boundEntities.add(entity.simpleName); if (!section || !readPath) { review.push({ kind: 'unresolved-section', entity: screen.entitySimpleName, section: key, detail: `Could not resolve section "${key}" (nav/permission seed) for ${screen.entitySimpleName}.`, }); continue; } const route = `${section.route.replace(/\/$/, '')}/{id}`; const categoryKey = pluralKebab(entity.simpleName); const line = `search.Entity<${entity.fqn ?? entity.simpleName}>("${categoryKey}", "${section.label}", "${section.icon}").RequirePermission("${readPath}").RouteTo(e => $"${route}")${entity.tenantScoped ? '.TenantScoped()' : ''}`; if (rowScopeSuspect.has(key)) { // FAIL-CLOSED: the section has assign/approve/reject → its list likely // row-scopes. Do NOT auto-register — the human must add the .RestrictTo(...). review.push({ kind: 'row-scope-suspect', entity: entity.simpleName, section: key, detail: `Section "${key}" carries a row-level action (assign/approve/reject) → its list probably restricts rows. NOT auto-registered: add the matching .RestrictTo(...) by hand so search cannot reveal hidden rows.`, suggestion: `// ${line}\n// .RestrictTo(scope => scope.Has("") ? null : e => e. == scope.UserId);`, }); continue; } registered.push({ entityName: entity.fqn ?? entity.simpleName, categoryKey, label: section.label, icon: section.icon, permission: readPath, route, tenantScoped: entity.tenantScoped, order: 100, }); } // Entities with no list-screen binding found. for (const e of input.entities) { if (!boundEntities.has(e.simpleName)) { review.push({ kind: 'unbound-entity', entity: e.simpleName, detail: `No list controller bound ${e.simpleName} to a section — not registered. Add it manually if it has a searchable list.`, }); } } return { spec: { appCode: input.appCode, contextType: input.contextType, projectPath: input.projectPath, entities: registered, }, review, }; } function pluralKebab(name: string): string { const lower = name.charAt(0).toLowerCase() + name.slice(1); const kebab = lower.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); if (kebab.endsWith('y') && !/[aeiou]y$/.test(kebab)) return kebab.slice(0, -1) + 'ies'; if (/(s|x|z|sh|ch)$/.test(kebab)) return kebab + 'es'; return kebab + 's'; } // ─── File IO ──────────────────────────────────────────────────────────────── function findFiles(root: string, predicate: (path: string) => boolean): string[] { if (!existsSync(root)) return []; const out: string[] = []; const walk = (dir: string) => { for (const ent of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, ent.name); if (ent.isDirectory()) { if (ent.name === 'bin' || ent.name === 'obj' || ent.name === 'node_modules') continue; walk(full); } else if (predicate(full)) { out.push(full); } } }; walk(root); return out; } const read = (p: string) => readFileSync(p, 'utf-8'); /** Read an existing project and derive the spec + review report. */ export function discoverFromProject(projectDir: string, appCode: string, contextType = 'ExtensionsDbContext'): DiscoveryResult { const infraRoot = join(projectDir, 'src', `${appCode}.Infrastructure`); const apiRoot = join(projectDir, 'src', `${appCode}.Api`); const appRoot = join(projectDir, 'src', `${appCode}.Application`); const domainRoot = join(projectDir, 'src', `${appCode}.Domain`); const permFiles = findFiles(infraRoot, (p) => p.endsWith('CorePermissionsSeedDataProvider.cs')); const navFiles = findFiles(infraRoot, (p) => p.endsWith('CoreNavigationSeedDataProvider.cs')); const ctxFiles = findFiles(infraRoot, (p) => p.endsWith('ExtensionsDbContext.cs')); const controllerFiles = findFiles(apiRoot, (p) => p.endsWith('Controller.cs')); const constFiles = findFiles(appRoot, (p) => p.endsWith('Permissions.cs')); const permissions = permFiles.flatMap((f) => parsePermissionSeed(read(f))); const sections = navFiles.flatMap((f) => parseNavSections(read(f))); // Permission-constant value map → resolves real (i18n) section codes from controller [RequirePermission] refs. const constMap = new Map(); for (const f of constFiles) for (const [k, v] of parsePermissionConstants(read(f))) constMap.set(k, v); const listScreens = controllerFiles.flatMap((f) => parseListControllers(read(f), constMap)); // Entities = DbSet declarations ∪ IEntityTypeConfiguration type args — // the standard ExtensionsDbContext template has ZERO DbSet (model mounted by // ApplyConfigurationsFromAssembly), so configurations are the reliable source. const configFiles = findFiles(infraRoot, (p) => p.endsWith('Configuration.cs')); const candidateEntities = [ ...ctxFiles.flatMap((f) => parseDbSetEntities(read(f))), ...configFiles.flatMap((f) => parseConfigurationEntities(read(f))), ]; const dedupedEntities: { simpleName: string; fqn?: string }[] = []; const seenEntityNames = new Set(); for (const e of candidateEntities) { if (seenEntityNames.has(e.simpleName)) continue; seenEntityNames.add(e.simpleName); dedupedEntities.push(e); } // Read every Domain file ONCE, indexed by the classes it declares. The // per-entity scan re-read the whole tree (and recompiled a RegExp) for each // candidate — bearable while only DbSet-declared entities existed, quadratic // now that Configurations enumerate them all, on a path the Phase 2 gate // runs for every module. const classIndex = new Map(); for (const df of findFiles(domainRoot, (p) => p.endsWith('.cs'))) { const src = read(df); for (const m of src.matchAll(/\bclass\s+([A-Za-z_]\w*)/g)) { if (!classIndex.has(m[1])) classIndex.set(m[1], src); } } const entities = dedupedEntities.map((e) => { // FAIL-CLOSED default: an entity whose Domain file could not be parsed is // treated as tenant-scoped. A wrong `.TenantScoped()` on a genuinely // global entity fails LOUDLY (compile/runtime); the opposite default // shipped a search that silently traversed tenants. const src = classIndex.get(e.simpleName); const parsed = src ? parseDomainEntity(src, e.simpleName) : null; return { simpleName: e.simpleName, fqn: e.fqn ?? parsed?.fqn, tenantScoped: parsed ? parsed.tenantScoped : true, }; }); return assemble({ appCode, projectPath: projectDir, contextType, permissions, sections, entities, listScreens }); }