import { mkdirSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { findSmartStackStructure } from '../../../lib/detector.js' import { resolveConnectionString, parseConnectionString } from '../../../lib/appsettings.js' import { FK_AUDIT_ALLOWLIST } from '../../../lib/fk-allowlist.js' import { fetchSchema } from './sql.js' import { classifyColumns, sortFindings } from './classify.js' import type { EntityAuditSpec, EntityAuditReport, ColumnFinding } from './types.js' export interface ExecuteResult { report: EntityAuditReport errors: string[] warnings: string[] nextSteps: string[] reportPath: string | null } export async function execute(spec: EntityAuditSpec): Promise { const warnings: string[] = [] // 1. Resolve the connection string (explicit override → appsettings*.json). let connStr = spec.connectionString ?? null let source = 'spec.connectionString' if (!connStr) { const structure = await findSmartStackStructure(spec.projectPath) if (!structure.api) { throw new Error(`Could not locate the API project (*.Api) under "${spec.projectPath}". Pass connectionString explicitly.`) } const resolved = resolveConnectionString(structure.api) if (!resolved.value) { throw new Error(`No ConnectionStrings:DefaultConnection found in appsettings*.json under "${structure.api}".`) } connStr = resolved.value source = resolved.source ?? 'appsettings' } const conn = parseConnectionString(connStr) // 2. Allowlist = built-in identity/audit columns + per-run extras. const allowlist = [...FK_AUDIT_ALLOWLIST, ...spec.allowlist.map((s) => new RegExp(s, 'i'))] // 3. Introspect the LIVE database, then classify. const { columns, fks, tables } = fetchSchema(conn, spec.schemas, spec.sqlcmdPath) const all = classifyColumns(columns, fks, tables, allowlist) const findings = sortFindings(all.filter((f) => f.classification !== 'ok')) const count = (c: ColumnFinding['classification']) => all.filter((f) => f.classification === c).length const report: EntityAuditReport = { database: conn.database, server: conn.server, schemasAudited: spec.schemas, totalIdColumns: all.length, criticalCount: count('critical'), reviewCount: count('review'), exemptCount: count('exempt'), okCount: count('ok'), findings, } const nextSteps: string[] = [] if (report.criticalCount > 0) { nextSteps.push( 'Add the missing FK constraints — every CRITICAL column references a real table with no foreign key. Re-run /ba-develop Phase 2 (scaffold-entity now emits Tenant/cross-module/core FKs) or add HasOne<…>().HasForeignKey(…) + regenerate the migration.', ) } if (report.reviewCount > 0) { nextSteps.push('Review each REVIEW column: confirm it is a genuinely external/opaque id (no principal table). Otherwise it needs a FK too.') } if (report.criticalCount === 0 && report.reviewCount === 0) { nextSteps.push('No missing foreign keys. Referential integrity is intact for the audited schemas.') } let reportPath: string | null = null if (spec.writeReport) { reportPath = join(spec.projectPath, '.smartstack', '_audit', 'entity-fk-audit.md') mkdirSync(dirname(reportPath), { recursive: true }) writeFileSync(reportPath, renderReport(report, source), 'utf-8') } return { report, errors: [], warnings, nextSteps, reportPath } } function renderReport(report: EntityAuditReport, source: string): string { const verdict = report.criticalCount > 0 ? '❌ FAIL' : report.reviewCount > 0 ? '⚠️ REVIEW' : '✅ PASS' const lines: string[] = [] lines.push('# Entity FK Audit — referential integrity verdict', '') lines.push(`**Verdict:** ${verdict}`, '') lines.push(`- Server / database: \`${report.server}\` / \`${report.database}\` (connection from ${source})`) lines.push(`- Schemas audited: ${report.schemasAudited.map((s) => `\`${s}\``).join(', ')}`) lines.push( `- \`*Id\` columns: ${report.totalIdColumns} — ` + `**${report.criticalCount} critical**, ${report.reviewCount} review, ${report.exemptCount} exempt, ${report.okCount} ok`, '', ) const section = (title: string, cls: ColumnFinding['classification']) => { const rows = report.findings.filter((f) => f.classification === cls) if (!rows.length) return lines.push(`## ${title} (${rows.length})`, '') lines.push('| Column | Candidate principal | Reason |', '|---|---|---|') for (const f of rows) { lines.push(`| \`${f.schema}.${f.table}.${f.column}\` | ${f.candidatePrincipal ? `\`${f.candidatePrincipal}\`` : '—'} | ${f.reason} |`) } lines.push('') } section('CRITICAL — missing FK to an existing table', 'critical') section('REVIEW — *Id with no resolvable principal', 'review') section('EXEMPT — identity/audit columns (intentionally not a FK)', 'exempt') lines.push('---', '', '_A cross-table reference without a FK violates referential integrity. The only non-FK `*Id` columns are the documented identity/audit allowlist (`lib/fk-allowlist.ts`)._') return lines.join('\n') + '\n' }