/** * cli:scaffold-time-entry-refs — discover.ts * * DISCOVERY for `ss upgrade`. IMPORTANT semantic difference vs scaffold-extension-search: * search discovers and REGISTERS the searchable entities it can bind deterministically. * Time-entry imputation is an opt-in BUSINESS decision — an entity is NOT searchable-by- * having-a-list-screen, it is an imputation target only if the client says so. So discovery * here NEVER registers anything. It only: * * (a) detects whether the seam markers already exist in the DI host; * (b) lets the caller back-fill the COMMENTED seam wrapper when it is missing (so * `ss upgrade` teaches an older project the seam, still registering nothing); * (c) returns the ExtensionsDbContext DbSet entities as `candidate` review items — a * human picks which (if any) become imputation dimensions. * * Fail-closed: unknown ⇒ not registered. */ import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; // ─── Pure parsers (operate on source strings → unit-testable) ────────────── /** 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 implements ITenantEntity. */ export function parseDomainEntity(src: string, simpleName: string): { fqn?: string; tenantScoped: boolean } | null { const ns = /namespace\s+([\w.]+)\s*;/.exec(src)?.[1]; const classRe = new RegExp(`class\\s+${simpleName}\\b([^{]*)`); const decl = classRe.exec(src); if (!decl) return null; const tenantScoped = /\bITenantEntity\b/.test(decl[1]); return { fqn: ns ? `${ns}.${simpleName}` : undefined, tenantScoped }; } function kebab(name: string): string { const lower = name.charAt(0).toLowerCase() + name.slice(1); return lower.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); } // ─── Assembly ────────────────────────────────────────────────────────────── export interface CandidateEntity { simpleName: string; fqn?: string; tenantScoped: boolean; } export interface ReviewItem { /** All discovery output for this CLI is advisory: a candidate a human may promote to a dimension. */ kind: 'candidate'; entity: string; detail: string; /** A ready-to-paste (commented) registration line for the human to complete. */ suggestion: string; } export interface DiscoveryResult { /** The extension DbSet entities, as advisory candidates (NONE are auto-registered). */ candidates: CandidateEntity[]; review: ReviewItem[]; /** Whether the DI host already carries the seam markers. */ seamPresent: boolean; } export interface AssembleInput { entities: CandidateEntity[]; seamPresent: boolean; } /** Turn discovered DbSet entities into advisory candidates. NEVER registers — imputation is opt-in. */ export function assemble(input: AssembleInput): DiscoveryResult { const review: ReviewItem[] = input.entities.map((e) => { const type = e.fqn ?? e.simpleName; const refType = kebab(e.simpleName); const scope = e.tenantScoped ? '.TenantScoped()' : ''; return { kind: 'candidate' as const, entity: e.simpleName, detail: `Extension entity "${e.simpleName}" could become a time-entry imputation dimension. Not auto-registered (imputation is opt-in) — add it by hand if time should be booked against it.`, suggestion: `// refs.Entity<${type}>("${refType}", "${e.simpleName}", "FolderKanban").WithDisplay(x => x.Name)${scope};`, }; }); return { candidates: input.entities, review, seamPresent: input.seamPresent }; } // ─── 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 candidate entities + seam presence. */ export function discoverFromProject(projectDir: string, appCode: string, _contextType = 'ExtensionsDbContext'): DiscoveryResult { const infraRoot = join(projectDir, 'src', `${appCode}.Infrastructure`); const domainRoot = join(projectDir, 'src', `${appCode}.Domain`); const ctxFiles = findFiles(infraRoot, (p) => p.endsWith('ExtensionsDbContext.cs')); const diFiles = [ join(infraRoot, 'DependencyInjection.cs'), join(infraRoot, 'ServiceCollectionExtensions.cs'), join(infraRoot, 'InfrastructureModule.cs'), ].filter((p) => existsSync(p)); const seamPresent = diFiles.some((f) => { const src = read(f); return src.includes('// <<< TIME-ENTRY-REFS-DI BEGIN >>>') && src.includes('// <<< TIME-ENTRY-REFS-DI END >>>'); }); const dbSetEntities = ctxFiles.flatMap((f) => parseDbSetEntities(read(f))); const domainFiles = findFiles(domainRoot, (p) => p.endsWith('.cs')); const entities: CandidateEntity[] = dbSetEntities.map((e) => { let fqn = e.fqn; let tenantScoped = false; for (const df of domainFiles) { const dfSrc = read(df); if (!new RegExp(`class\\s+${e.simpleName}\\b`).test(dfSrc)) continue; const parsed = parseDomainEntity(dfSrc, e.simpleName); if (parsed) { fqn = fqn ?? parsed.fqn; tenantScoped = parsed.tenantScoped; break; } } return { simpleName: e.simpleName, fqn, tenantScoped }; }); return assemble({ entities, seamPresent }); }