/** * cli:extract-doc — extract.ts * * The deterministic extraction engine. All file IO is async (lib/fs) and * NEVER throws on missing sources — it returns nulls/empties and pushes a * human-readable note into `warnings`, so a partially-scaffolded project still * yields a useful report. * * Replaces the legacy repo-root scripts (extract-api-endpoints.ts / * extract-business-rules.ts) — same parsing heuristics, but layer locations * come from lib/detector (findSmartStackStructure) instead of hardcoded * `SmartStack.Api` / `SmartStack.Domain` paths, so it works on any generated * client project (MyApp.Api, MyApp.Domain, …). */ import path from 'node:path' import { findSmartStackStructure, detectFrontendMode, SMARTSTACK_WEB_PACKAGE, type FrontendModeInfo, type SmartStackStructure, } from '../../../lib/detector.js' import { extractNavRoutes } from '../../../lib/navroute-parser.js' import { findFiles, readText, fileExists, directoryExists } from '../../../lib/fs.js' import { buildAccessRoles, deriveModuleCode, loadAccessSources } from './access-roles.js' import { toPascalCase, toKebabCase, singularize, pluralize, capitalize } from '../../../lib/string-utils.js' import type { ExtractDocInput, ExtractReport, ChartInfo, ApiEndpoint, BusinessRule, EntityProperty, ExistingDocReport, ForbiddenSection, OverflowRisk, } from './types.js' const RECHARTS_MOCK: Record = { PieChart: 'donut/pie — SVG circle stroke-dasharray or CSS conic-gradient', Pie: 'donut/pie — SVG circle stroke-dasharray or CSS conic-gradient', Doughnut: 'donut — SVG circle stroke-dasharray', LineChart: 'line — SVG polyline / path', AreaChart: 'area — SVG path with fill', RadialBarChart: 'radial bar — SVG circle stroke-dasharray', } export async function extract(input: ExtractDocInput, warnings: string[]): Promise { const { type, target, application, projectPath } = input const structure = await findSmartStackStructure(projectPath) const modeInfo: FrontendModeInfo = structure.web ? await detectFrontendMode(structure.web) : { mode: 'unknown', packageVersion: null, evidence: ['web project not found'] } const pascal = toPascalCase(target) const singular = capitalize(singularize(target)) const camelNamespace = `docs${application ? toPascalCase(application) : ''}${pascal}` // Client projects register i18n through the moduleResources channel, where // the namespace IS the locale file name — kebab. Refined from the navRoute // below (§5) so the namespace always agrees with suggestedRoutes[0]. const namespace = modeInfo.mode === 'client' ? toKebabCase(camelNamespace) : camelNamespace if (modeInfo.mode === 'client' && type !== 'user') { warnings.push( `CLIENT project detected but type is '${type}' — client projects support ONLY user-type docs ` + `(DocRenderer is not exported by ${SMARTSTACK_WEB_PACKAGE}); scaffold-doc will refuse.`, ) } const report: ExtractReport = { type, target, application: application ?? null, namespace, namespaceStyle: modeInfo.mode === 'client' ? 'kebab-client' : 'camel-source', suggestedRegistryFile: null, resolved: { projectRoot: projectPath, webRoot: structure.web ? rel(projectPath, structure.web) : null, apiDir: structure.api ? rel(projectPath, structure.api) : null, domainDir: structure.domain ? rel(projectPath, structure.domain) : null, frontendMode: modeInfo.mode, packageVersion: modeInfo.packageVersion, pageTsxPath: null, pageCandidatesTried: [], }, navRoute: null, suggestedRoutes: [], charts: [], apiEndpoints: [], businessRules: [], entity: null, accessRoles: { source: 'none', codePermissions: [], rows: [], unmappedCodePermissions: [], warnings: [] }, existingDoc: { found: false, files: [], forbiddenSections: [], missingRequired: [], adviseRolesTable: false, overflowRisks: [], }, } // ── 1. Locate the real page TSX + 2. detect its charts (user type) ─────── if (type === 'user' && structure.web) { // Explicit pagePath wins; otherwise auto-detect from src/pages by name. if (input.pagePath) { const abs = path.resolve(projectPath, input.pagePath) if (await fileExists(abs)) { report.resolved.pageTsxPath = rel(projectPath, abs) report.charts = await detectCharts(abs, structure.web, projectPath, warnings) } else { warnings.push(`Provided pagePath not found: ${input.pagePath} — falling back to auto-detection.`) } } if (!report.resolved.pageTsxPath) { const pagesDir = path.join(structure.web, 'src', 'pages') if (await directoryExists(pagesDir)) { const allPages = await findFiles('**/*.tsx', { cwd: pagesDir }) const needles = [pluralize(pascal), pascal, singular].map((n) => n.toLowerCase()) const matches = allPages.filter((f) => { const base = path.basename(f, '.tsx').toLowerCase() return base.endsWith('page') && needles.some((n) => base.includes(n)) }) report.resolved.pageCandidatesTried = matches.map((f) => rel(projectPath, f)) // Prefer a list/template page (the module's landing UI), else shortest name. const preferred = matches.find((f) => /(list|template)page\.tsx$/i.test(path.basename(f))) ?? [...matches].sort((a, b) => path.basename(a).length - path.basename(b).length)[0] if (preferred) { report.resolved.pageTsxPath = rel(projectPath, preferred) report.charts = await detectCharts(preferred, structure.web, projectPath, warnings) } else { warnings.push( `No page TSX found under src/pages matching "${pascal}". The Mock UI must be authored from the live app / pagespec.`, ) } } else { warnings.push(`Web pages dir not found: ${rel(projectPath, pagesDir)}`) } } } else if (type === 'user') { warnings.push('Web project not detected — cannot locate the real page; Mock UI must be authored manually.') } // ── 3. API endpoints from the controller ───────────────────────────────── if (structure.api) { const controller = await findOne(structure.api, [`**/${pascal}*Controller.cs`, `**/${singular}*Controller.cs`]) if (controller) { const content = await readText(controller) report.navRoute = extractNavRoutes(content)[0]?.fullNavRoute ?? null const permMap = await loadPermissionConstants(structure, warnings) report.apiEndpoints = extractEndpoints(content, report.navRoute, permMap) if (report.apiEndpoints.length === 0) { warnings.push(`Controller found (${rel(projectPath, controller)}) but no endpoints parsed.`) } } else { warnings.push(`No controller matching "${pascal}" found under ${report.resolved.apiDir}.`) } } else { warnings.push('API layer not detected — API endpoints not extracted.') } // ── 4. Entity props + business rules from the domain entity (+ tests) ───── if (structure.domain) { const entityFile = await findOne(structure.domain, [`**/${singular}.cs`, `**/${pascal}.cs`]) if (entityFile) { const content = await readText(entityFile) report.entity = { name: path.basename(entityFile, '.cs'), file: rel(projectPath, entityFile), properties: extractEntityProperties(content), } const domainRules = extractRulesFromEntity(content) // Domain guard clauses are authoritative. Unit tests mostly re-encode the // same guards (noisy), so only fall back to tests when the entity yields none. const testRules = domainRules.length > 0 ? [] : await extractRulesFromTests(projectPath, singular, pascal) report.businessRules = formatRules(mergeRules(domainRules, testRules)) } else { warnings.push(`No domain entity found for "${singular}" — business rules / entity props not extracted.`) } } else { warnings.push('Domain layer not detected — business rules not extracted.') } // ── 5. Suggested routes (best-effort, for the wiring step) ──────────────── if (report.navRoute) { const base = report.navRoute.replace(/\./g, '/') report.suggestedRoutes = [base, `${base}/list`, `${base}/create`] } else if (application) { report.suggestedRoutes = [`${application}/${target}`] } // ── 5b. « Accès & rôles » join — code = source of truth, state/BA enrich ── // Roles come from the committed core-seed state, portée/labels from the BA // rbac.md, both keyed STRICTLY on the permissions the controller enforces. // Feeds the user doc's Section 2 role table AND the DocRenderer types' // `permissions[].roles`. Read-only and never failing: no state + no BA → // source 'none' and the doc keeps its plain access fallback. { const navSegs = report.navRoute?.split('.') ?? [] const appCodes = [...new Set([navSegs[0], application].filter((s): s is string => Boolean(s)))] const codePermissions = report.apiEndpoints.map((e) => e.permission) if (codePermissions.length > 0) { const moduleCode = deriveModuleCode(codePermissions, appCodes) ?? navSegs[1] ?? target const { state, rbacRows } = await loadAccessSources(projectPath, appCodes, moduleCode, warnings) report.accessRoles = buildAccessRoles({ codePermissions, appCodes: state ? [...new Set([...appCodes, state.application])] : appCodes, state, rbacRows, }) } else if (type === 'user') { report.accessRoles = buildAccessRoles({ codePermissions: [], appCodes, state: null, rbacRows: null }) } } // Client mode: the kebab namespace must agree with the doc route (scaffold-doc // enforces kebab === 'docs-' + routePath with '-'), so refine it from the // navRoute when one was found (the authoritative app/module path). if (modeInfo.mode === 'client') { if (report.navRoute) { report.namespace = `docs-${report.navRoute.replace(/\./g, '-').toLowerCase()}` } if (structure.web) { report.suggestedRegistryFile = rel( projectPath, path.join(structure.web, 'src', 'extensions', `${report.namespace}Registry.ts`), ) } } // ── 6. Scan the EXISTING doc for structural compliance ──────────────────── // Mirror checks (SKILL.md rule #12): // - forbiddenSections: problème/solution, Bénéfices, Avant/Après must be ABSENT. // - missingRequired: the factual "objective" Section 1 (all types) and the // header accent tagline "summary" (user) must be PRESENT. // Surfaces all so generation fixes them (step-02); the step-03 gate re-runs // this scan and blocks until the doc is clean AND complete. // NON-blocking advice (never in the gate): the « Accès & rôles » table when a // roles source exists (adviseRolesTable) and the overflow-prone `` / // `font-mono` elements (overflowRisks). report.existingDoc = await scanExistingDoc( structure.web, target, report.namespace, projectPath, type, report.accessRoles.source !== 'none', ) return report } // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── function rel(root: string, abs: string): string { return path.relative(root, abs).replace(/\\/g, '/') } // ── Forbidden-section scan (problème/solution, Bénéfices, Avant/Après) ────── /** * Detect the banned "sales" framing in a single doc artifact (PURE — unit-tested). * * Signals are structural to keep false positives near zero (a FAQ *answer* that * merely contains the word "solution" is NOT flagged — only the structural key is): * - i18n JSON → a KEY named problem | solution | benefits | beforeAfter * - doc-data.ts → an object KEY benefits | beforeAfter | problem | solution * - *.tsx → a `t('…benefits/beforeAfter/problem/solution…')` reference, * or a hardcoded marketing section title (Bénéfices / Avant / Après). */ export function findForbidden(content: string, relFile: string): ForbiddenSection[] { const out: ForbiddenSection[] = [] const isJson = relFile.endsWith('.json') const isTs = /\.tsx?$/.test(relFile) const lines = content.split('\n') for (let i = 0; i < lines.length; i++) { const line = lines[i] // A single line can carry several forbidden keys (compact JSON / TS) — collect them all. const tokens = new Set() if (isJson) { for (const m of line.matchAll(/"(problem|solution|benefits|beforeAfter)"\s*:/g)) tokens.add(m[1]) } else if (isTs) { for (const m of line.matchAll(/(?:^|[{,\s])(benefits|beforeAfter|problem|solution)\s*:/g)) tokens.add(m[1]) for (const m of line.matchAll(/\bt\(\s*['"][^'"]*\b(problem|solution|benefits|beforeAfter)\b/g)) tokens.add(m[1]) if (/Bénéfices|Avant\s*\/\s*Après/.test(line)) tokens.add('literal-title') } for (const token of tokens) { out.push({ file: relFile, line: i + 1, token, snippet: line.trim().slice(0, 120) }) } } return out } /** * Detect the mandatory `objective` section in a single doc artifact (PURE — * unit-tested). The mirror of `findForbidden`: the doc must LEAD with a factual * objective (the Section 1 that replaced problème/solution). * * Structural signals (same low-false-positive philosophy): * - i18n JSON → an `"objective"` KEY (top-level for `user`, or `sections.objective` * / `overview.objective` for DocRenderer types). * - doc-data.ts → an `objective:` object KEY (e.g. `overview: { objective: … }`). * - *.tsx → a `t('…objective…')` reference rendering the section. * * Tolerates the French key spelling `objectif` so a doc that HAS the section but * keyed it `objectif` is not falsely reported missing (the gate asserts the * section exists, not the exact key spelling — instructions push `objective`). */ export function hasObjective(content: string, relFile: string): boolean { if (relFile.endsWith('.json')) return /"objecti(?:ve|f)"\s*:/.test(content) if (/\.tsx?$/.test(relFile)) { return /(?:^|[{,\s])objecti(?:ve|f)\s*:/.test(content) || /\bt\(\s*['"][^'"]*\bobjecti(?:ve|f)\b/.test(content) } return false } /** * Detect the header **accent tagline** in a `user` doc (PURE — unit-tested): the * one-line accent summary between the title and the subtitle. Its absence makes * the header inconsistent (some pages show it, some don't — what the user flagged). * * Canonical i18n key is `summary`; `valueProposition` is the legacy alias and is * still accepted so a doc using it is not falsely flagged. Signals: * - i18n JSON → a `"summary"` / `"valueProposition"` KEY. * - *.tsx → a `t('summary')` / `t('valueProposition')` reference. */ export function hasSummary(content: string, relFile: string): boolean { if (relFile.endsWith('.json')) return /"(?:summary|valueProposition)"\s*:/.test(content) if (/\.tsx?$/.test(relFile)) return /\bt\(\s*['"][^'"]*\b(?:summary|valueProposition)\b/.test(content) return false } /** * Detect the Section 2 « Accès & rôles » role table in a `user` doc artifact * (PURE — unit-tested). Drives the NON-blocking `adviseRolesTable` advice: * an existing doc predating the table is flagged for its next regeneration, * never gated (rétrocompat decision — legacy docs keep working untouched). * * Structural signals: * - i18n JSON → an `"access"` object carrying a `"columns"` or `"roles"` KEY * (the legacy shape `access.navigation` alone is NOT the table). * - *.tsx → a `t('access.columns.*')` / `t('access.roles.*')` reference. */ export function hasAccessRoles(content: string, relFile: string): boolean { if (relFile.endsWith('.json')) return /"access"\s*:\s*\{[\s\S]{0,200}?"(?:columns|roles)"\s*:/.test(content) if (/\.tsx?$/.test(relFile)) return /\bt\(\s*['"`]access\.(?:columns|roles)\./.test(content) return false } /** * Detect overflow-prone technical tokens in a doc page (PURE — unit-tested): * every `` element and every `font-mono` element must carry a break * utility (`break-all` / `break-words` / `overflow-wrap`). Without it a long * unbreakable token (URL, permission key, namespace) fixes a wide min-content * on its grid/flex cell and overflows the card — the PERMISSION / URL * regression the gate blocks. * * Only the page TSX carries layout, so non-`.tsx` sources (i18n JSON, * doc-data.ts) are never scanned. A `` reports a * single `code-no-break` finding (code precedence — no double report). */ export function findOverflowRisks(content: string, relFile: string): OverflowRisk[] { if (!relFile.endsWith('.tsx')) return [] const out: OverflowRisk[] = [] const lines = content.split('\n') const tagRe = /<([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g const breakRe = /break-all|break-words|overflow-wrap/ for (let i = 0; i < lines.length; i++) { const line = lines[i] for (const m of line.matchAll(tagRe)) { const tag = m[0] const name = m[1] const cls = tag.match(/className=(?:"([^"]*)"|\{`([^`]*)`\})/) const className = cls ? (cls[1] ?? cls[2] ?? '') : '' if (breakRe.test(className)) continue if (name === 'code') { out.push({ file: relFile, line: i + 1, token: 'code-no-break', snippet: line.trim().slice(0, 120) }) } else if (/\bfont-mono\b/.test(className)) { out.push({ file: relFile, line: i + 1, token: 'mono-no-break', snippet: line.trim().slice(0, 120) }) } } } return out } /** * Locate the EXISTING doc artifacts for a module (the generated doc page + * doc-data.ts + the central i18n files) and scan each for structural compliance: * forbidden "sales" sections (must be absent) AND the mandatory objective (must * be present). Read-only and null-safe: a not-yet-documented module yields * `found: false` with empty findings (nothing to require before it exists). */ async function scanExistingDoc( web: string | undefined, target: string, namespace: string, projectRoot: string, type: string, hasRolesSource: boolean, ): Promise { const empty: ExistingDocReport = { found: false, files: [], forbiddenSections: [], missingRequired: [], adviseRolesTable: false, overflowRisks: [], } if (!web) return empty const kebab = toKebabCase(namespace) // docsSupportSla → docs-support-sla const patterns = [ `**/pages/docs/business/**/${target}/index.tsx`, // user + DocRenderer page `**/pages/docs/business/**/${target}/doc-data.ts`, // DocRenderer data `**/i18n/locales/*/${kebab}.json`, // central i18n (all langs) ] const absFiles: string[] = [] for (const pattern of patterns) { for (const f of await findFiles(pattern, { cwd: web })) { if (!absFiles.includes(f)) absFiles.push(f) } } const forbiddenSections: ForbiddenSection[] = [] const overflowRisks: OverflowRisk[] = [] const files: string[] = [] let objectivePresent = false let summaryPresent = false let accessRolesPresent = false for (const abs of absFiles) { const relFile = rel(projectRoot, abs) const content = await readText(abs) // Scope page/data candidates to THIS doc by namespace. The page/data globs // match by folder name only (`target`), so a name shared across apps (e.g. // myspace/tenants AND administration/tenants) would mis-attribute another // module's findings. A page references its namespace (`useTranslation('')`), // a doc-data its key prefix (`'.overview…'`). The i18n `.json` files are // already filename-scoped (`docs-.json`) and carry no namespace key, so // they are never filtered. if (!relFile.endsWith('.json') && !content.includes(namespace)) continue files.push(relFile) forbiddenSections.push(...findForbidden(content, relFile)) overflowRisks.push(...findOverflowRisks(content, relFile)) if (hasObjective(content, relFile)) objectivePresent = true if (hasSummary(content, relFile)) summaryPresent = true if (hasAccessRoles(content, relFile)) accessRolesPresent = true } // Only require sections once a doc actually exists (post-generation gate), // never before it is authored. const found = files.length > 0 const missingRequired: string[] = [] if (found && !objectivePresent) missingRequired.push('objective') // The accent header tagline is a `user`-page element (DocRenderer types render // their own header), so only require `summary` there. if (found && type === 'user' && !summaryPresent) missingRequired.push('summary') // NON-blocking (never in missingRequired): the « Accès & rôles » table is only // ADVISED, and only when the project actually has a roles source — a legacy // doc on a source-less project is never nagged. const adviseRolesTable = found && type === 'user' && hasRolesSource && !accessRolesPresent return { found, files, forbiddenSections, missingRequired, adviseRolesTable, overflowRisks } } /** First file matching any of the patterns under baseDir (shortest basename wins). */ async function findOne(baseDir: string, patterns: string[]): Promise { for (const p of patterns) { const files = await findFiles(p, { cwd: baseDir }) if (files.length > 0) { return [...files].sort((a, b) => path.basename(a).length - path.basename(b).length)[0] } } return null } /** Resolve a `@/...` import to a real source file under {webRoot}/src/. */ async function resolveAlias(importPath: string, webRoot: string): Promise { const relPath = importPath.replace(/^@\//, 'src/') const baseNoExt = path.join(webRoot, relPath) for (const cand of [`${baseNoExt}.tsx`, `${baseNoExt}.ts`, path.join(baseNoExt, 'index.tsx')]) { if (await fileExists(cand)) return cand } return null } function mockPatternFor(rechartsType: string | null, layout: string | null): string { if (!rechartsType) return 'unknown chart — open the source component and match its recharts type' if (rechartsType === 'BarChart') { return layout === 'vertical' ? 'horizontal bar list (div width:%) — Recharts layout="vertical"' : 'vertical histogram (flex items-end, bars height:%)' } return RECHARTS_MOCK[rechartsType] ?? 'see templates.md → recharts → Mock UI mapping' } /** Find chart components imported from @/components/{dashboard,charts} and classify each. */ async function detectCharts( pageFile: string, webRoot: string, projectRoot: string, warnings: string[], ): Promise { const content = await readText(pageFile) const charts: ChartInfo[] = [] const rechartsRe = /<(BarChart|PieChart|LineChart|AreaChart|RadialBarChart|Doughnut|Pie)\b/ const layoutRe = /layout\s*=\s*["'](vertical|horizontal)["']/ // (a) Chart components imported from @/components/{dashboard,charts}. const importRe = /import\s+(?:\{([^}]*)\}|(\w+))\s+from\s+['"](@\/components\/(?:dashboard|charts)\/[^'"]+)['"]/g for (const m of content.matchAll(importRe)) { const named = (m[1] ?? '') .split(',') .map((s) => s.trim()) .filter(Boolean) const components = named.length > 0 ? named : m[2] ? [m[2]] : [] const importPath = m[3] const sourceFile = await resolveAlias(importPath, webRoot) let rechartsType: string | null = null let layout: string | null = null if (sourceFile) { const src = await readText(sourceFile) rechartsType = src.match(rechartsRe)?.[1] ?? null layout = src.match(layoutRe)?.[1] ?? null } for (const component of components) { // Skip non-chart dashboard helpers (KpiCard, StatCard…): no recharts AND // not chart-named. They are not graphics that need a Mock UI pattern. if (!rechartsType && !/chart|graph/i.test(component)) continue if (!sourceFile) warnings.push(`Chart source not resolved for import "${importPath}" — inspect it manually.`) charts.push({ component, importPath, sourceFile: sourceFile ? rel(projectRoot, sourceFile) : null, rechartsType, layout, mockUiPattern: mockPatternFor(rechartsType, layout), }) } } // (b) recharts used INLINE in the page itself (no wrapper component). if (/from\s+['"]recharts['"]/.test(content)) { const inlineType = content.match(rechartsRe)?.[1] ?? null if (inlineType) { const layout = content.match(layoutRe)?.[1] ?? null charts.push({ component: '(inline recharts)', importPath: 'recharts', sourceFile: null, rechartsType: inlineType, layout, mockUiPattern: mockPatternFor(inlineType, layout), }) } } return charts } // ── API endpoints (ported from scripts/extract-api-endpoints.ts) ────────── async function loadPermissionConstants( structure: SmartStackStructure, warnings: string[], ): Promise> { const map = new Map() const dirs = [structure.application, structure.api, structure.apiCore, structure.domain].filter( (d): d is string => Boolean(d), ) let permFile: string | null = null for (const dir of dirs) { const files = await findFiles('**/Permissions.cs', { cwd: dir }) if (files.length > 0) { permFile = files[0] break } } if (!permFile) { warnings.push('Permissions.cs not found — permissions resolved by name inference.') return map } const content = await readText(permFile) const lines = content.split('\n') let ns = '' for (const line of lines) { const classMatch = line.match(/public\s+(?:static\s+)?(?:partial\s+)?class\s+(\w+)/) if (classMatch) { ns = ns ? `${ns}.${classMatch[1]}` : `Permissions.${classMatch[1]}` } const constMatch = line.match(/public\s+const\s+string\s+(\w+)\s*=\s*"([^"]+)"/) if (constMatch && ns) { map.set(`${ns}.${constMatch[1]}`, constMatch[2]) } if (line.trim() === '}' && ns) { const lastDot = ns.lastIndexOf('.') ns = lastDot > 0 ? ns.substring(0, lastDot) : '' } } return map } function resolvePermission(constantPath: string, map: Map, navRoute: string): string { if (map.has(constantPath)) return map.get(constantPath)! const parts = constantPath.split('.') if (parts.length >= 3) { const action = parts[parts.length - 1].toLowerCase() const actionMap: Record = { view: 'read', read: 'read', create: 'create', update: 'update', delete: 'delete', assign: 'assign', execute: 'execute', export: 'export', import: 'import', } return `${navRoute}.${actionMap[action] ?? action}` } return `${navRoute}.unknown` } function extractEndpoints(content: string, navRoute: string | null, permMap: Map): ApiEndpoint[] { const lines = content.split('\n') const endpoints: ApiEndpoint[] = [] const basePath = navRoute ? `/api/${navRoute.replace(/\./g, '/')}` : '/api' const httpMethods = ['Get', 'Post', 'Put', 'Delete', 'Patch'] as const let curMethod: { method: string; suffix: string } | null = null let curPermission: string | null = null for (const line of lines) { for (const method of httpMethods) { const match = line.match(new RegExp(`\\[Http${method}(?:\\("([^"]+)"\\))?\\]`)) if (match) { curMethod = { method: method.toUpperCase(), suffix: match[1] ? `/${match[1]}` : '' } break } } const permMatch = line.match(/\[RequirePermission\(([^)]+)\)\]/) if (permMatch) { const arg = permMatch[1].includes(',') ? permMatch[1].split(',')[0].trim() : permMatch[1].trim() curPermission = resolvePermission(arg, permMap, navRoute ?? '') continue } if (curMethod) { const handler = line.match(/public\s+(?:async\s+)?Task.*?\s+(\w+)\s*\(/)?.[1] if (handler) { endpoints.push({ method: curMethod.method, path: basePath + curMethod.suffix, handler, permission: curPermission ?? (navRoute ? `${navRoute}.unknown` : ''), }) curMethod = null curPermission = null } } } return endpoints } // ── Entity properties ────────────────────────────────────────────────────── function extractEntityProperties(content: string): EntityProperty[] { const props: EntityProperty[] = [] const re = /public\s+([A-Za-z0-9_<>[\]?]+)\s+(\w+)\s*\{\s*get/g const skip = /(^id$|id$|hash|token|normalized|password|salt|concurrencystamp|securitystamp)/i const seen = new Set() for (const m of content.matchAll(re)) { const name = m[2] if (skip.test(name) || seen.has(name)) continue seen.add(name) const rawType = m[1] props.push({ name, type: rawType.replace(/\?$/, ''), nullable: rawType.endsWith('?') }) } return props } // ── Business rules (ported from scripts/extract-business-rules.ts) ───────── interface RawRule { source: 'domain' | 'test' exception: string context: string category: string } function extractRulesFromEntity(content: string): RawRule[] { const lines = content.split('\n') const rules: RawRule[] = [] // Dedupe by exception text: the same guard ("Email is required") recurs across // Create/Update/… methods, and a one-line `if (…) throw` matches on both the // `if` line and the `throw` line — both would otherwise double-count. const seen = new Set() let curMethod = '' const add = (exception: string, context: string): void => { const key = exception.toLowerCase() if (seen.has(key)) return seen.add(key) rules.push({ source: 'domain', exception, context, category: inferCategory(exception, context) }) } for (let i = 0; i < lines.length; i++) { const line = lines[i] const methodMatch = line.match(/public\s+(?:static\s+)?(?:[\w<>?[\]]+)\s+(\w+)\s*\(/) if (methodMatch) curMethod = methodMatch[1] const directThrow = line.match(/throw\s+new\s+(?:\w+\.)?DomainException\s*\(\s*"([^"]+)"/) if (directThrow) { add(directThrow[1], curMethod) continue } const guard = line.match(/if\s*\((.+)\)/) if (guard && i + 1 < lines.length) { const next = lines[i + 1].trim().match(/throw\s+new\s+(?:\w+\.)?DomainException\s*\(\s*"([^"]+)"/) if (next) add(next[1], `${curMethod} (${guard[1].trim()})`) } } return rules } async function extractRulesFromTests(projectPath: string, singular: string, pascal: string): Promise { const testFile = (await findOne(projectPath, [`**/${singular}Tests.cs`, `**/${pascal}Tests.cs`])) ?? null if (!testFile) return [] const content = await readText(testFile) const lines = content.split('\n') const rules: RawRule[] = [] let curTest = '' for (const line of lines) { const testMatch = line.match(/public\s+(?:async\s+)?(?:void|Task)\s+(\w+)\s*\(/) if (testMatch) curTest = testMatch[1] const assert = line.match(/Should\(\)\.Throw\(\)(?:\.WithMessage\("([^"]+)"\))?/) if (assert && curTest) { rules.push({ source: 'test', exception: exceptionFromTestName(curTest, assert[1] ?? ''), context: curTest, category: inferCategory(curTest, curTest), }) } } return rules } function exceptionFromTestName(testName: string, hint: string): string { const parts = testName.split('_') if (parts.length >= 3) { const cond = parts[1].replace(/^With/, '') if (cond.includes('Empty')) return `${capitalize(cond.replace('Empty', '').toLowerCase())} is required` if (cond.includes('Invalid')) return `Invalid ${cond.replace('Invalid', '').toLowerCase()}` if (cond.includes('Duplicate')) return `${capitalize(cond.replace('Duplicate', '').toLowerCase())} must be unique` } return hint.replace(/\*/g, '') || testName } function inferCategory(text: string, context: string): string { const l = text.toLowerCase() if (l.includes('required') || l.includes('empty') || l.includes('null')) return 'Validation' if (l.includes('unique') || l.includes('duplicate') || l.includes('exist')) return 'Validation' if (l.includes('password') || l.includes('token') || l.includes('auth')) return 'Securite' if (l.includes('permission') || l.includes('role') || l.includes('access')) return 'Autorisation' if (l.includes('tenant') || l.includes('isolation')) return 'Multi-tenant' if (l.includes('email') || l.includes('notification')) return 'Communication' if (l.includes('audit') || l.includes('log') || l.includes('track')) return 'Audit' if (/create|update|delete/i.test(context)) return 'Logique metier' return 'Autre' } function mergeRules(domainRules: RawRule[], testRules: RawRule[]): RawRule[] { const merged = [...domainRules] const seen = new Set(domainRules.map((r) => r.exception.toLowerCase())) for (const r of testRules) { if (!seen.has(r.exception.toLowerCase())) { merged.push(r) seen.add(r.exception.toLowerCase()) } } return merged } function formatRules(rules: RawRule[]): BusinessRule[] { return rules.map((rule, i) => ({ id: `BR-${String(i + 1).padStart(3, '0')}`, name: ruleName(rule.exception), category: rule.category || 'Autre', statement: rule.exception, })) } function ruleName(exception: string): string { const l = exception.toLowerCase() const reqIdx = l.indexOf(' is required') if (reqIdx > 0) return `${exception.slice(0, reqIdx)} requis` const uniqueIdx = l.indexOf(' must be unique') if (uniqueIdx > 0) return `Unicite ${exception.slice(0, uniqueIdx).toLowerCase()}` if (l.includes('password') && (l.includes('least') || l.includes('strong'))) return 'Mot de passe fort' if (l.includes('email') && l.includes('valid')) return 'Email valide' const words = exception.split(' ').slice(0, 5).join(' ') return words.length > 50 ? `${words.substring(0, 47)}...` : words }