/** * apply-remediation.ts — A refused or gated `apply` says HOW to lift the verdict. * * The 🔴 tier of the apply policy carries NO confirmation token: the exit is * structural — the developer names their working database in the sanctioned * per-developer layer (`appsettings.Local.json`, seeded by `ss dev configure`), or a * deployed environment is migrated by deploying the app. Until this module, the * envelope only said "blocked" and the reader had to open the CLI's source to learn * that `confirm: true` would not help and that `efcore.localHosts` even existed. * * `confirmable` is the machine-readable fact (🟡 → true, 🔴 → false). Every `note` * is addressed to an agent: the declaration is a USER decision — never written by * the agent to green-wash its own verdict. * * Pure — no I/O. */ import type { PolicyVerdict, DbClassification } from './migration-policy.js'; import type { ConfigSource } from './connection-provenance.js'; import type { SquashBranchType } from './squash-base.js'; export type RemediationKind = | 'confirm' // 🟡 — re-run with `confirm: true` | 'declare-working-db' // 🔴 committed / spec string on a non-local host | 'unset-environment-connection' // 🔴 the env var names the DB (deployment context) | 'configure-connection' // 🔴 nothing resolves / no Server= token | 'protected-branch' // 🔴 main | 'deploy-app'; // 🔴 Azure SQL named by a committed file export interface WorkingDbHint { file: 'appsettings.Local.json'; key: 'ConnectionStrings:DefaultConnection'; /** The sanctioned writer — and the reminder that the file belongs to the user. */ writer: string; note: string; } export interface LocalHostsHint { file: '.gitflow/config.json'; path: 'efcore.localHosts'; value: string; note: string; } export interface ApplyRemediation { /** True when a token lifts the verdict (🟡 `confirm`). False on every 🔴. */ confirmable: boolean; confirmField?: 'confirm'; kind: RemediationKind; /** One sentence for the human: what to do next. */ message: string; /** Name YOUR working database in the per-developer layer. */ workingDb?: WorkingDbHint; /** Alternative for a committed string on a LAN host: a one-time USER declaration. */ localHosts?: LocalHostsHint; /** Why the CLI is not the deploy tool. */ platformNote?: string; } export const WORKING_DB_HINT: WorkingDbHint = { file: 'appsettings.Local.json', key: 'ConnectionStrings:DefaultConnection', writer: 'ss dev configure (the auto-seed proposal the user confirms, one database per branch) — or the user writes it: it is their gitignored file', note: 'user decision — the agent never edits this file, nor sets ConnectionStrings__DefaultConnection, to green-wash a verdict', }; export const LOCAL_HOSTS_NOTE = 'user decision — the agent must not write this file'; export const PLATFORM_NOTE = 'the generated app applies pending migrations itself at startup (every environment) — a deployed environment is migrated by deploying the app, never by this CLI'; const AZURE_SUFFIX = '.database.windows.net'; export interface RemediationInput { policy: PolicyVerdict; dbClass: DbClassification; source: ConfigSource; branchType: SquashBranchType; } /** Build the remediation block for a non-green verdict; `undefined` on 🟢. */ export function buildApplyRemediation(input: RemediationInput): ApplyRemediation | undefined { const { policy, dbClass, source } = input; if (policy.tier === 'green') return undefined; if (policy.tier === 'yellow') { return { confirmable: true, confirmField: 'confirm', kind: 'confirm', message: `Re-run with "confirm": true to proceed — ${policy.reason}`, }; } // 🔴 — no token. Name the structural exit. const host = dbClass.host; if (policy.axis === 'branch') { return { confirmable: false, kind: 'protected-branch', message: 'Migrations are never applied from a protected branch — switch to the feature branch that owns the change, or deploy the app.', platformNote: PLATFORM_NOTE, }; } if (source.kind === 'environment') { return { confirmable: false, kind: 'unset-environment-connection', message: 'ConnectionStrings__DefaultConnection is set in this shell and names the database dotnet ef would target — a deployment setting, not a sandbox. Unset it here, or name YOUR working database in appsettings.Local.json and run from a shell without it.', workingDb: WORKING_DB_HINT, platformNote: PLATFORM_NOTE, }; } if (!host) { return { confirmable: false, kind: 'configure-connection', message: source.kind === 'none' ? 'No ConnectionStrings:DefaultConnection resolves from appsettings*.json — run `ss dev configure` (it seeds appsettings.Local.json with one database per branch) or name your working database there.' : `The connection string from ${source.file ?? source.kind} carries no Server= / Data Source= token — fix it, or name your working database in appsettings.Local.json.`, workingDb: WORKING_DB_HINT, }; } if (host.endsWith(AZURE_SUFFIX)) { return { confirmable: false, kind: 'deploy-app', message: `"${host}" is an Azure SQL database named by ${source.file ?? 'the spec'} — a deployed environment migrates itself at startup: deploy the app. If it is YOUR development instance, name it in appsettings.Local.json on a feature branch.`, workingDb: WORKING_DB_HINT, platformNote: PLATFORM_NOTE, }; } return { confirmable: false, kind: 'declare-working-db', message: `"${host}" is named by ${source.file ?? 'the spec'} — a ${source.kind === 'committed' ? 'committed, team-shared' : 'caller-supplied'} connection string is never treated as a sandbox. If it is YOUR working database, name it in appsettings.Local.json (gitignored — ss dev configure seeds it per branch) and re-run from a feature branch.`, workingDb: WORKING_DB_HINT, localHosts: { file: '.gitflow/config.json', path: 'efcore.localHosts', value: host, note: LOCAL_HOSTS_NOTE, }, }; }