SLOPSHOPPER

migration-guard

Makes Claude ask before it touches your database migrations or runs destructive SQL, with the risk and what it found.

newguardcommandtoaststatus
v0.1.1MITupdated 2026-10-05balen-abd/claude-migration-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · migration-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /migration-guard ⎿ migration-guard: Nothing asked yet. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

migration-guard

A Claude Code mod that makes Claude ask before it touches your database migrations.

I let Claude Code work on a large Postgres codebase every day, and it's good at it. But migrations are the one place where "looks fine" isn't enough. A single generated file can drop a column or quietly lose a pile of indexes, and you find out in production. So this makes Claude ask me first, and only for migrations and the database.

Quick start

  1. Make sure Claude Code is 2.1.287 or newer: claude --version. If it's older, run claude update.
  2. Install it:
   git clone https://github.com/balen-abd/claude-migration-guard ~/.claude/skills/migration-guard
  1. Check it's on: claude -p /migration-guard should print migration-guard: Nothing asked yet.

That's all. Next time Claude goes to change a migration, it asks you first. Windows, updating, removing and other ways to load it are under Install; what you can change is under Settings.

What you see

 High risk
 Claude wants to write a migration file: .../db/migrations/0042_cleanup.sql.
 DROP COLUMN: deletes the column and its data; DROP INDEX: can make queries
 slow. Found: ALTER TABLE "users" DROP COLUMN "email". Allow it?

 1. No                 Block it. Claude is told to stop and not work around it.
 2. Yes                Allow this once.
 3. Yes for this file  Don't ask again for this file, unless something more
                       destructive shows up.

No comes first, so an accidental Enter (or anything that picks the first option for you) blocks.

Commands get the same treatment:

 High risk
 Claude wants to run prisma: npx prisma migrate reset --force.
 It can wipe the database or drop data. Allow it?

Say no, or type what you want instead ("make a new migration, don't edit this one"), and Claude is told to stop and not work around it.

When it asks

  • Claude writes or edits a migration file: TypeORM, Prisma, Rails, Django, Alembic, Knex, Sequelize, Laravel, Doctrine, EF Core, Flyway, Liquibase, goose, Supabase, Drizzle and more.
  • Claude runs migrations: npm run migration:run, pnpm --filter api db:migrate, prisma migrate deploy, rails db:rollback, alembic downgrade, php artisan migrate, dotnet ef database update, make migrate, and about 30 other tools.
  • Claude changes migration files from the shell or a script: cat >, sed -i, rm, git checkout --, find ... -delete, Remove-Item, a node -e/python -c one-liner, or a Python heredoc that rewrites one.
  • Claude runs SQL straight against a database: psql -c "DROP TABLE ...", psql -f file.sql, cat x.sql | psql, mongosh --eval "db.users.drop()", dropdb, docker compose down -v.
  • Claude uses an MCP server that migrates or runs SQL: Supabase's apply_migration, execute_sql with a DROP in it, Prisma's migrate-reset, or a filesystem server writing into migrations/.

It watches Claude Code's Bash tool, its PowerShell tool on Windows, and its Monitor tool, so a command can't slip past by going through a different one.

Everything else goes through untouched, including anything that only reads or mentions a migration: grep ... | head, cat, commit messages and PR bodies (heredocs included), and scripts that edit a doc which happens to talk about migrations.

Risk levels

LevelWhat counts
HighDROP TABLE, DROP COLUMN, DROP DATABASE/SCHEMA, TRUNCATE, DELETE or UPDATE that hits every row (WHERE 1=1 counts), commands that undo, reset, push or wipe
MediumDROP INDEX, DROP CONSTRAINT, column type changes, renames, emptying a migration or deleting part of one, running pending migrations
LowA migration with nothing destructive in it

Each risky command says why: "it undoes applied migrations, and their down steps usually drop things", "it pushes the schema straight to the database and can drop data to make it match", and so on.

To keep it quiet:

  • ORM calls are named after the SQL they run. queryRunner.dropColumn, remove_column, RemoveField, op.drop_column and migrationBuilder.DropColumn all show up as DROP COLUMN.
  • For a whole migration file it reads only the part that runs forward, everything before down() or -- +goose Down, because the undo half always drops what the forward half created. Undo files (.down.sql, Flyway's U2__x.sql) are Low risk for the same reason.
  • DROP TABLE inside a SQL comment doesn't count.
  • When Claude edits a migration, the question reminds you that editing one that already ran won't change the database.

A batch of migrations

When Claude is writing several migration files, pick "Yes to all files, 15 min". For the next 15 minutes ordinary migration file changes go through without asking. High-risk ones still ask, and commands always ask.

Turning it off

  • /migration-guard off turns it off for this session; /migration-guard off 30 for 30 minutes.
  • /migration-guard on turns it back on. So does restarting Claude Code, so it can't stay off by accident.
  • While it's off, the status line says so, and everything that went through still shows up in the history.
  • Only you can do this. If Claude (or a script, or another mod) sends the command, it's refused.
  • For good: set mode to off in /config, or delete the folder.

History

Type /migration-guard to see what it asked and what you answered, newest first:

Last 3 of 3 (1 blocked). /migration-guard all shows 300, /migration-guard clear empties it.
2026-10-04 20:56  blocked (nobody answered)  [medium]  run a package script that migrates: npm run migration:run  in .../acme/api
2026-10-04 20:41  went through (yes to all files)  [low]  write a migration file: .../db/migrations/0043_add_index.sql  in .../acme/api
2026-10-04 20:40  Yes to all files, 15 min  [low]  write a migration file: .../db/migrations/0042_orders.sql  in .../acme/api

It also lists the changes that went through without a question because of an earlier "Yes for this file" or "Yes to all files", so you can see what happened while you weren't looking. It keeps the last 300, in the store Claude Code gives each mod (not a file in your project), with passwords and tokens in commands masked. Set keepHistory to off if you don't want it.

Install

One command. Claude Code loads any mod it finds in ~/.claude/skills/, so there's nothing else to set up:

git clone https://github.com/balen-abd/claude-migration-guard ~/.claude/skills/migration-guard

On Windows, in PowerShell:

git clone https://github.com/balen-abd/claude-migration-guard "$HOME\.claude\skills\migration-guard"

Check it's on:

claude -p /migration-guard

It should print migration-guard: Nothing asked yet. If you get anything else, run claude --debug and look for a migration-guard line saying why it didn't load.

You need Claude Code 2.1.287 or newer (claude --version). Mods are early access: older builds, which includes the stable channel at the time of writing (2.1.285), can have them switched off. Either update, or add "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" to the env block of ~/.claude/settings.json.

Update: git -C ~/.claude/skills/migration-guard pull. An open session picks it up, and any "Yes for this file" answers start over.

Remove: delete the folder.

Keeping mods somewhere else? Point CLAUDE_CODE_PLUGIN_DIRS at the folder in the env block of ~/.claude/settings.json, or use claude --plugin-dir <folder> for one session. Use one way, not two, or every question comes twice.

Settings

In /config, under migration-guard:

SettingDefault
modealwaysdestructive-only asks only when something destructive or risky shows up; off never asks
extraPathPatterna regex for migration paths it misses, e.g. schema/changes/
extraCommandPatterna regex for commands it misses, e.g. ./scripts/deploy-db.sh
keepHistoryonoff keeps no history at all

If a pattern isn't a valid regex, it tells you when the session starts and in /migration-guard, instead of quietly ignoring it.

Or in ~/.claude/settings.json. The key is migration-guard@skills-dir for the install above, and migration-guard if you load it with CLAUDE_CODE_PLUGIN_DIRS or --plugin-dir:

{
  "pluginConfigs": {
    "migration-guard@skills-dir": {
      "options": { "mode": "destructive-only", "extraPathPattern": "schema/changes/" }
    }
  }
}

Claude Code reads these from your user settings (or --settings), not from a project's .claude/settings.json, so a repo can't change them behind your back.

Is it safe?

Don't take my word for it. It's small enough to check:

  • About 1,300 lines of TypeScript in hooks/. detect.ts decides what counts, question.ts is the wording, history.ts is /migration-guard, register.ts wires it into Claude Code.
  • Mods run in their own environment, with no Node. They can't touch the disk, the network or other processes except through Claude Code's $ API, and claude plugin validate . lists every $ call a mod makes. This one asks you ($.ui.ask, $.ui.toast, $.ui.status), reads the time and the folder name for the history, keeps the history in its own store ($.store), and adds the /migration-guard command. It also hooks AskUserQuestion, but only to touch its own questions (see below). Nothing reads your files, runs anything, or sends anything anywhere: `` > ./register.ts hooks: session.start, tool.call{tool=AskUserQuestion}, command.run{command=migration-guard}, tool.call{tool=Write}, tool.call{tool=Edit}, tool.call{tool=/"^(Bash|PowerShell|Monitor)$"/}, tool.call{tool=/"^mcp__"/} > ./register.ts calls: $.clock.now (via confirm, guardCommand, remember, stateLine), $.command.register, $.session.cwd (via remember), $.store.get (via guardCommand, remember), $.store.set (via guardCommand, remember), $.ui.ask (via askPerson), $.ui.status (via confirm, guardCommand), $.ui.toast ``
  • If nobody answers, the change is blocked. That covers claude -p and CI, a dismissed question, and one more case I only found by testing live. In VS Code, Claude Code's question dialog answers itself after about 30 seconds with nobody at the keyboard, and it picked "Yes". The guard now recognizes that kind of answer (the dialog marks it) and treats it as no answer, and No is the first option in case anything picks the first one.
  • In VS Code the question can sit behind a notification that says "Claude is requesting permission to use AskUserQuestion". Click View to see it.
  • If the guard itself crashes on a migration file or a migration command, it blocks too. In destructive-only mode a safe migration goes through without a question, headless or not.
  • It doesn't time out on you. Claude Code pauses a hook's clock while it waits for your answer.
  • Subagents go through the same guard.

What it won't catch

  • Migrations run from inside your own script (./deploy.sh), or by your app when it starts (TypeORM's migrationsRun: true and the like). Add the script to extraCommandPattern.
  • A migration path built at runtime when the command never says "migrations" (rm $DIR/x.sql).
  • MCP tools with unusual names. It goes by the tool's name (apply_migration, execute_sql, run_sql, write_file...) and what's in the arguments.
  • Another mod that answers Claude Code's question dialog for you. Only install mods you trust.
  • Anything you run yourself. It guards Claude, not your terminal.
  • It reads up to 1 MB per check, so in destructive-only mode something past that point in a huge file won't be scanned.
  • It doesn't work at all if it doesn't load: an old Claude Code, or a future update that changes the mods API. That's what "Check it's on" and the weekly CI run are for.

It doesn't replace backups. Keep them, and give Claude a database user that can't drop things in production.

Tests

claude plugin validate --strict .
claude plugin test .

100 tests. CI runs them on Linux, macOS and Windows, against 2.1.287 and the latest Claude Code, every week.

To see if it's actually worth having, I replayed every tool call from 365 of my own past Claude Code sessions through it: 26,157 shell commands and 682 file writes and edits. It would have asked 46 times. 21 were migration files Claude wrote or edited, 23 were migration files changed from the shell or by a Python script, and 2 were real npm run migration:run runs. As far as I can tell only one was a false alarm: deleting from a scratch copy of the migrations folder. The other 26,000-odd commands went through without a word.

I also ran it for real. By hand on 2.1.287: no, yes, "Yes for this file", and a subagent's write. Headless with claude -p on 2.1.289, installed with the one git clone above: a destructive migration write and npm run migration:run were blocked, an ordinary file went through, destructive-only set in settings let a safe migration through, and /migration-guard showed it all.

License

MIT

Source 4 files
hooks/register.ts 349 lines
1import type { EngineInterface, Register } from 'claude-code'
2import {
3  approvalKey,
4  classifyCommand,
5  classifyMcpCall,
6  findDestructive,
7  isEffectivelyEmpty,
8  isMigrationPath,
9  isUndoFile,
10  looksMigrationRelated,
11  removedBuild,
12  safeRegex,
13  shortPath,
14  undoFileNote,
15  usesHashComments,
16  type Finding,
17} from './detect'
18import { HISTORY_KEY, append, asEntries, render, type Entry } from './history'
19import { ALL_WINDOW_MS, NO, YES, YES_ALL, YES_COMMAND, YES_FILE, describeOption, levelOf, optionsFor, questionFor, type Ask } from './question'
20
21// "Yes for this file/command": the key, and the destructive kinds the person
22// had seen. A new kind asks again. A reload of the mod starts it over.
23const approved = new Map<string, Set<string>>()
24// "Yes to all files": until when migration file changes that aren't high risk go through.
25let allowAllUntil = 0
26
27const EDIT_HINT = "If it already ran, editing it won't change the database."
28
29// The history behind /migration-guard lives only in the store, read fresh on
30// every write: several Claude Code sessions can share it, and a copy kept here
31// would bring back what another session cleared.
32let keepHistory = true
33let lastWrite: Promise<void> = Promise.resolve()
34
35/** One history write at a time in this session, so two at once can't lose one. */
36async function takeTurn(): Promise<() => void> {
37  const before = lastWrite
38  let release = () => {}
39  lastWrite = new Promise<void>((resolve) => {
40    release = resolve
41  })
42  await before
43  return release
44}
45
46/** Writes down what happened. History is a convenience: it never blocks or breaks the guard. */
47async function remember($: EngineInterface, ask: Ask, answer: string) {
48  if (!keepHistory) return
49  const done = await takeTurn()
50  try {
51    const list = asEntries(await $.store.get(HISTORY_KEY))
52    const cwd = await $.session.cwd()
53    const at = await $.clock.now()
54    const entry: Entry = { at, project: shortPath(cwd), what: ask.what, target: ask.target, risk: levelOf(ask), labels: ask.findings.map((f) => f.label), answer }
55    await $.store.set(HISTORY_KEY, append(list, entry))
56  } catch {
57    // A store or clock that isn't there (a test, an old build) just means no history.
58  } finally {
59    done()
60  }
61}
62
63// The off switch: /migration-guard off [minutes] until when, Infinity for "until on".
64// Per session, and a reload of the mod turns it back on, so it can't stay off by accident.
65let offUntil = 0
66
67// The guard's own questions while they're on screen, so the AskUserQuestion
68// hook below touches only those and leaves everyone else's alone.
69const ourQuestions = new Set<string>()
70
71/**
72 * Asks the person and resolves to what they picked or typed, or undefined
73 * when nobody really answered (dismissed, headless, or the dialog answered
74 * itself while they were away; the AskUserQuestion hook turns that into no
75 * answer).
76 */
77async function askPerson($: EngineInterface, ask: Ask): Promise<string | undefined> {
78  const { header, question } = questionFor(ask)
79  ourQuestions.add(question)
80  try {
81    return await $.ui.ask(question, { header, options: optionsFor(ask).map((o) => o.label) })
82  } catch {
83    return undefined
84  } finally {
85    ourQuestions.delete(question)
86  }
87}
88
89/** Asks the person; resolves to a deny reason, or undefined when they said yes. */
90async function confirm($: EngineInterface, ask: Ask) {
91  if (offInSettings) return undefined
92  if (offUntil > 0) {
93    if (offUntil === Infinity || offUntil > (await $.clock.now())) {
94      await remember($, ask, 'went through (guard was off)')
95      return undefined
96    }
97    offUntil = 0 // a timed "off" ran out
98    $.ui.status(undefined)
99  }
100  const seen = approved.get(ask.key)
101  if (seen && ask.findings.every((f) => seen.has(f.label))) {
102    await remember($, ask, 'went through (said yes for it earlier)')
103    return undefined
104  }
105  if (ask.kind === 'file' && allowAllUntil > 0 && levelOf(ask) !== 'high' && allowAllUntil > (await $.clock.now())) {
106    await remember($, ask, 'went through (yes to all files)')
107    return undefined
108  }
109
110  const answer = await askPerson($, ask)
111  if (answer === undefined) {
112    await remember($, ask, 'blocked (nobody answered)')
113    return `${$.plugin.name}: no confirmation for "${ask.target}" (the question was dismissed, or nobody was there to answer). Do not retry; ask the user how to proceed.`
114  }
115  await remember($, ask, answer === YES || answer === YES_FILE || answer === YES_COMMAND || answer === YES_ALL || answer === NO ? answer : `"${answer}"`)
116
117  if (answer === YES) return undefined
118  if (answer === YES_FILE || answer === YES_COMMAND) {
119    approved.set(ask.key, new Set([...(seen ?? []), ...ask.findings.map((f) => f.label)]))
120    return undefined
121  }
122  if (answer === YES_ALL) {
123    allowAllUntil = (await $.clock.now()) + ALL_WINDOW_MS
124    return undefined
125  }
126  $.ui.toast(`blocked: ${ask.target}`)
127  const said = answer === NO ? '' : ` They said: "${answer}"`
128  return `${$.plugin.name}: the user did not allow Claude to ${ask.what} (${ask.target}).${said} Do not retry or work around it; follow what they said, or ask how they want to proceed.`
129}
130
131// Settings that were filled in but aren't valid regular expressions, so they do nothing.
132let badSettings: string[] = []
133
134/** A warning line for settings that are being ignored, or nothing. */
135function settingsWarning(): string {
136  return badSettings.length > 0 ? `${badSettings.join(' and ')} in /config isn't a valid regular expression, so it's ignored.` : ''
137}
138
139// The guard turned off for good in /config (mode: off).
140let offInSettings = false
141
142/** One line on whether the guard is on, for the top of /migration-guard. */
143async function stateLine($: EngineInterface): Promise<string> {
144  if (offInSettings) return 'The guard is off (mode: off in /config).'
145  if (offUntil === Infinity) return 'The guard is off until you type /migration-guard on.'
146  if (offUntil === 0) return ''
147  const left = offUntil - (await $.clock.now())
148  return left > 0 ? `The guard is off for ${Math.ceil(left / 60_000)} more min. /migration-guard on turns it back on.` : ''
149}
150
151/**
152 * /migration-guard: the history, or the off switch. Only the person can flip
153 * the switch: a command that didn't come from their own keyboard (Claude, a
154 * script, another plugin) is refused.
155 */
156async function guardCommand($: EngineInterface, args: string | undefined, fromPerson: boolean): Promise<string> {
157  const [word = '', minutes] = (args ?? '').trim().split(/\s+/)
158  const lines = [settingsWarning()]
159  if (word === 'off' || word === 'on') {
160    if (!fromPerson) return 'Only you can turn the guard off or on, by typing /migration-guard yourself.'
161    const now = await $.clock.now()
162    const span = Number(minutes)
163    if (word === 'off') {
164      offUntil = Number.isFinite(span) && span > 0 ? now + span * 60_000 : Infinity
165      const until = offUntil === Infinity ? 'until you turn it back on' : `for ${span} min`
166      $.ui.status(`migration-guard: off ${offUntil === Infinity ? '' : `for ${span} min`}`.trim())
167      await remember($, { kind: 'command', key: '', what: 'turn the guard off', target: until, findings: [] }, 'by you')
168      return `The guard is off ${until}. Migration changes go through without asking, and still show up here. /migration-guard on turns it back on.`
169    }
170    offUntil = 0
171    $.ui.status(undefined)
172    await remember($, { kind: 'command', key: '', what: 'turn the guard on', target: 'now', findings: [] }, 'by you')
173    return 'The guard is on again.'
174  }
175  lines.push(await stateLine($))
176  if (!keepHistory) return [...lines, 'History is off (keepHistory in /config).'].filter(Boolean).join('\n')
177  const done = await takeTurn()
178  try {
179    if (word === 'clear') {
180      await $.store.set(HISTORY_KEY, [])
181      return [...lines, 'History cleared.'].filter(Boolean).join('\n')
182    }
183    lines.push(render(asEntries(await $.store.get(HISTORY_KEY)), word === 'all' ? Number.MAX_SAFE_INTEGER : 20))
184    return lines.filter(Boolean).join('\n')
185  } finally {
186    done()
187  }
188}
189
190/**
191 * When a guard hook itself fails, Claude Code would run the tool unguarded.
192 * Block instead if the call is about migrations; let anything else through.
193 */
194function failClosed(about: boolean, name: string) {
195  return about
196    ? { deny: `${name}: the guard hit an internal error, so this was blocked to be safe. Ask the user to run it themselves, or to check the mod.` }
197    : undefined
198}
199
200function clip(text: string, max: number): string {
201  return text.length > max ? `${text.slice(0, max - 3)}...` : text
202}
203
204/**
205 * How a command shows in the question: whole when it's one short line;
206 * otherwise just the part that matters, on one line, with "..." when that
207 * part isn't where the command starts.
208 */
209function commandTarget(command: string, part: string): string {
210  if (!command.includes('\n') && command.length <= 120) return command
211  const shown = clip(part.replace(/\s+/g, ' ').trim(), 113)
212  return shown.startsWith('...') || command.trimStart().startsWith(part.trim().slice(0, 20)) ? shown : `... ${shown}`
213}
214
215/** What a file's own path says: an undo file only runs on rollback, so its drops are expected. */
216function fileFindings(path: string, findings: () => Finding[]): { findings: Finding[]; note?: string } {
217  return isUndoFile(path) ? { findings: [], note: undoFileNote() } : { findings: findings() }
218}
219
220export const register: Register = (on, options) => {
221  const onlyDestructive = options.mode === 'destructive-only'
222  const extraPath = safeRegex(options.extraPathPattern)
223  const extraCommand = safeRegex(options.extraCommandPattern)
224  keepHistory = options.keepHistory !== 'off'
225  offInSettings = options.mode === 'off'
226  const filled = (value: unknown) => typeof value === 'string' && value.trim() !== ''
227  badSettings = [
228    ...(filled(options.extraPathPattern) && !extraPath ? ['extraPathPattern'] : []),
229    ...(filled(options.extraCommandPattern) && !extraCommand ? ['extraCommandPattern'] : []),
230  ]
231
232  on('session.start', async ($, e, next) => {
233    await $.command.register({
234      name: 'migration-guard',
235      description: 'What migration-guard asked and what you answered; off/on to pause it',
236      argumentHint: '[all | clear | off [minutes] | on]',
237    })
238    const warning = settingsWarning()
239    if (warning) $.ui.toast(`migration-guard: ${warning}`)
240    return next(e)
241  })
242
243  // The guard's own questions, on their way to the dialog and back. Claude Code's
244  // dialog answers itself after a while with nobody at the keyboard (it picked
245  // "Yes" in VS Code on 2.1.289) and marks the result with afkTimeoutMs; that
246  // answer is turned into no answer, so the change is blocked. On the way in,
247  // each option gets its one-line description, which $.ui.ask leaves empty.
248  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
249    const first = e.questions[0]
250    if (!first || !ourQuestions.has(first.question)) return next(e)
251    const options = first.options.map((o) => ({ ...o, description: describeOption(o.label) || o.description }))
252    const r = await next({ ...e, questions: [{ ...first, options }, ...e.questions.slice(1)] })
253    if (r.deny !== undefined || !r.result) return r
254    const result = r.result as { answers?: Record<string, unknown>; response?: unknown; afkTimeoutMs?: unknown }
255    if (result.afkTimeoutMs !== undefined) return { deny: 'Nobody answered: the question timed out with nobody at the keyboard.' }
256    // What the person typed instead of picking an option counts as their answer.
257    const typed = typeof result.response === 'string' ? result.response.trim() : ''
258    const answers = result.answers ?? {}
259    if (typed && typeof answers[first.question] !== 'string') {
260      return { ...r, result: { ...result, answers: { ...answers, [first.question]: typed } } }
261    }
262    return r
263  })
264
265  // The person's own Enter, at the terminal or through a bridge to their own device.
266  on('command.run', { command: 'migration-guard' }, async ($, e) => ({
267    text: await guardCommand($, e.args, e.origin.kind === 'composer' || e.origin.kind === 'bridge'),
268  }))
269
270  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
271    if (!isMigrationPath(e.file_path, extraPath)) return next(e)
272    const found = isEffectivelyEmpty(e.content, e.file_path)
273      ? { findings: [{ label: 'Empties the file', snippet: '' }] }
274      : fileFindings(e.file_path, () => findDestructive(e.content, { skipDown: true, hashComments: usesHashComments(e.file_path) }))
275    if (onlyDestructive && found.findings.length === 0) return next(e)
276    const deny = await confirm($, {
277      kind: 'file',
278      key: approvalKey(e.file_path),
279      what: 'write a migration file',
280      target: shortPath(e.file_path),
281      ...found,
282    })
283    return deny ? { deny } : next(e)
284  }).catch(($, e, next) =>
285    next.called ? next(e) : (failClosed(isMigrationPath(e.file_path, extraPath) || looksMigrationRelated(e.content), $.plugin.name) ?? next(e)),
286  )
287
288  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
289    if (!isMigrationPath(e.file_path, extraPath)) return next(e)
290    // Deleting the text outright, or dropping a CREATE INDEX/TABLE and keeping the rest.
291    const emptied = e.new_string.trim() === '' && e.old_string.trim() !== ''
292    const gone = emptied ? e.old_string.trim().split('\n')[0]?.slice(0, 80) : removedBuild(e.old_string, e.new_string)
293    const found =
294      gone !== undefined
295        ? { findings: [{ label: 'Removes code', snippet: gone ?? '' }] }
296        : fileFindings(e.file_path, () => findDestructive(e.new_string, { hashComments: usesHashComments(e.file_path) }))
297    if (onlyDestructive && found.findings.length === 0) return next(e)
298    const deny = await confirm($, {
299      kind: 'file',
300      key: approvalKey(e.file_path),
301      what: 'edit a migration file',
302      target: shortPath(e.file_path),
303      hint: EDIT_HINT,
304      ...found,
305    })
306    return deny ? { deny } : next(e)
307  }).catch(($, e, next) =>
308    next.called ? next(e) : (failClosed(isMigrationPath(e.file_path, extraPath) || looksMigrationRelated(e.new_string), $.plugin.name) ?? next(e)),
309  )
310
311  // Bash, the PowerShell tool on Windows, and Monitor all run shell commands.
312  on('tool.call', { tool: /^(Bash|PowerShell|Monitor)$/ }, async ($, e, next) => {
313    const command = 'command' in e && typeof e.command === 'string' ? e.command : ''
314    const hit = classifyCommand(command, { extra: extraCommand, extraPath, powershell: String(e.tool) === 'PowerShell' })
315    if (!hit) return next(e)
316    if (onlyDestructive && !hit.risky) return next(e)
317    const deny = await confirm($, {
318      kind: 'command',
319      key: command,
320      what: hit.reason,
321      target: commandTarget(command, hit.part),
322      findings: hit.findings,
323      note: hit.note,
324      baseRisk: hit.risky ? 'high' : 'medium',
325    })
326    return deny ? { deny } : next(e)
327  }).catch(($, e, next) =>
328    next.called ? next(e) : (failClosed('command' in e && typeof e.command === 'string' && looksMigrationRelated(e.command), $.plugin.name) ?? next(e)),
329  )
330
331  // MCP servers: database ones (Supabase, Neon, Prisma...) and ones that write files.
332  on('tool.call', { tool: /^mcp__/ }, async ($, e, next) => {
333    const args = Object.fromEntries(Object.entries(e).filter(([key]) => !['tool', 'tool_use_id', 'agentId'].includes(key)))
334    const hit = classifyMcpCall(String(e.tool), args, extraPath)
335    if (!hit) return next(e)
336    if (onlyDestructive && !hit.risky && hit.findings.length === 0) return next(e)
337    const deny = await confirm($, {
338      kind: hit.kind,
339      key: hit.key,
340      what: hit.reason,
341      target: hit.target,
342      findings: hit.findings,
343      note: hit.note || undefined,
344      baseRisk: hit.kind === 'file' ? undefined : hit.risky ? 'high' : 'medium',
345    })
346    return deny ? { deny } : next(e)
347  }).catch(($, e, next) => (next.called ? next(e) : (failClosed(looksMigrationRelated(JSON.stringify(e)), $.plugin.name) ?? next(e))))
348}
349
hooks/detect.ts 1012 lines
1// Decides what counts as touching a migration, and what in it can destroy
2// data. No engine calls in here, so every rule can be tested on its own.
3
4export type Finding = { label: string; snippet: string }
5
6export type CommandHit = {
7  /** What the command does, as the question shows it ("run prisma"). */
8  reason: string
9  /** True when the command can lose data or undo work by its own name. */
10  risky: boolean
11  /** One short line on why it matters, shown when no destructive SQL was spotted. */
12  note: string
13  /** Destructive SQL in the command itself. */
14  findings: Finding[]
15  /** The step that matched, shown when the whole command is too long to read in the question. */
16  part: string
17}
18
19const NOTE = {
20  applies: 'it applies pending migrations to the database',
21  wipes: 'it can wipe the database or drop data',
22  undoes: 'it undoes applied migrations, and their down steps usually drop things',
23  pushes: 'it pushes the schema straight to the database and can drop data to make it match',
24  forced: "it's forced, so the tool's own safety checks are skipped",
25  history: 'it changes which migrations count as applied, so history can drift',
26  sqlDirect: 'it runs straight against the database, with no migration to review',
27  sqlFile: "the guard can't see what the file does",
28  shellDeletes: 'it deletes migration files; if one already ran, history can drift',
29  shellWrites: "it changes migration files outside the file tools, so there's no diff to review",
30  scriptWrites: "a script rewrites it, so there's no diff to review",
31  volumes: 'any database data in those volumes is gone for good',
32  dropDb: 'it deletes the whole database',
33  restore: 'it writes a dump into the database',
34  restoreClean: 'it drops what is there before restoring',
35  mcp: 'it applies the change to the database right away',
36  extra: 'you listed it in extraCommandPattern',
37  undoFile: "it's an undo file, so it only runs on rollback",
38  tooLong: "it's over 1 MB, so the guard can't read all of it",
39}
40
41// Even big generated migrations stay well under this. The cap keeps every
42// check fast when someone pastes something enormous.
43const MAX_SCAN = 1024 * 1024
44function capped(text: string): string {
45  return text.length > MAX_SCAN ? text.slice(0, MAX_SCAN) : text
46}
47
48// ---------------------------------------------------------------- files
49
50const MIGRATION_DIRS: readonly RegExp[] = [
51  /(^|[\\/])migrations?[\\/]/i, // migrations/ or migration/ (TypeORM, Prisma, Knex, Supabase, Flyway, EF Core...)
52  /(^|[\\/])db[\\/]migrate[\\/]/i, // Rails
53  /(^|[\\/])alembic[\\/]versions[\\/]/i, // Alembic
54  /(^|[\\/])db[\\/]changelog[\\/]/i, // Liquibase
55  /(^|[\\/])db\.changelog[\w.-]*$/i, // Liquibase master changelog
56  /(^|[\\/])drizzle[\\/]([^\\/]+\.sql|meta[\\/][^\\/]+\.json)$/i, // drizzle-kit output and its journal
57  /(^|[\\/])sqitch[\\/](deploy|revert)[\\/]/i, // Sqitch
58  /(^|[\\/])conf[\\/]evolutions[\\/]/i, // Play
59]
60
61const CODE_FILE = /\.(p?g?sql|[cm]?[jt]sx?|py|rb|go|rs|php|java|kt|cs|swift|exs?|xml|ya?ml|json)$/i
62// Tests and fixtures that sit next to migrations, and code other people ship.
63const NOT_OURS = /\.(spec|test)\.[^.\\/]+$|_(test|spec)\.\w+$|[\\/]test_[^\\/]+\.py$|[\\/](__tests__|node_modules|vendor|site-packages)[\\/]/i
64// golang-migrate's .down.sql, Diesel's down.sql, Flyway's U2__undo.sql
65const UNDO_FILE = /(^|[\\/._-])down\.sql$|(^|[\\/])U\d+(_\d+)*__[^\\/]*\.sql$/i
66
67/** A migration file. Docs, READMEs and tests that live in a migrations folder are not. */
68export function isMigrationPath(path: string, extra?: RegExp): boolean {
69  if (extra?.test(path)) return true
70  if (!CODE_FILE.test(path) || NOT_OURS.test(path)) return false
71  if (/\.json$/i.test(path) && !/changelog|drizzle/i.test(path)) return false // JSON only for Liquibase and Drizzle
72  return MIGRATION_DIRS.some((re) => re.test(path))
73}
74
75/** A file that only runs on rollback, where drops are the whole point. */
76export function isUndoFile(path: string): boolean {
77  return UNDO_FILE.test(path)
78}
79
80export function undoFileNote(): string {
81  return NOTE.undoFile
82}
83
84// ---------------------------------------------------- destructive statements
85
86type Rule = readonly [RegExp, string | ((m: RegExpExecArray) => string)]
87
88const RULES: readonly Rule[] = [
89  [/\bDROP\s+TABLE\b/i, 'DROP TABLE'],
90  [/\bDROP\s+COLUMN\b/i, 'DROP COLUMN'],
91  // Postgres and MySQL let you leave out COLUMN: ALTER TABLE users DROP email
92  [
93    /\bALTER\s+TABLE\s+(?:ONLY\s+)?(?:IF\s+EXISTS\s+)?[\w."`[\]]+\s+DROP\s+(?!COLUMN\b|CONSTRAINT\b|INDEX\b|KEY\b|DEFAULT\b|NOT\b|IDENTITY\b|EXPRESSION\b|PRIMARY\b|FOREIGN\b|CHECK\b|PARTITION\b)["`[\w]/i,
94    'DROP COLUMN',
95  ],
96  [/\bDROP\s+(UNIQUE\s+)?(INDEX|KEY)\b/i, 'DROP INDEX'], // DROP KEY is MySQL for an index
97  [/\bDROP\s+(CONSTRAINT|FOREIGN\s+KEY|PRIMARY\s+KEY)\b/i, 'DROP CONSTRAINT'],
98  [/\bDROP\s+PARTITION\b/i, 'DROP PARTITION'],
99  [
100    /\bDROP\s+(SCHEMA|DATABASE|MATERIALIZED\s+VIEW|VIEW|TYPE|TRIGGER|FUNCTION|PROCEDURE|EXTENSION|SEQUENCE|OWNED|POLICY)\b/i,
101    (m) => `DROP ${(m[1] ?? '').toUpperCase().replace(/\s+/g, ' ')}`,
102  ],
103  [/\bDROP\b[^;]{0,200}?(?<!\bON\s+(DELETE|UPDATE)\s+)\bCASCADE\b/i, 'CASCADE'], // takes everything that depends on it along
104  // Several actions in one ALTER: ALTER TABLE users ADD COLUMN a int, DROP b
105  [/\bALTER\s+TABLE\b[^;]{0,500}?,\s*DROP\s+(?!CONSTRAINT\b|INDEX\b|KEY\b|DEFAULT\b|NOT\b|IDENTITY\b|EXPRESSION\b|PRIMARY\b|FOREIGN\b|CHECK\b|PARTITION\b)["`[\w]/i, 'DROP COLUMN'],
106  [/\bTRUNCATE\b(?!\s*(,|ON\b))/i, 'TRUNCATE'], // not GRANT ... TRUNCATE ON, or a trigger's AFTER TRUNCATE ON
107  [/\bALTER\s+(COLUMN\s+)?\S+\s+(SET\s+DATA\s+)?TYPE\b/i, 'ALTER COLUMN TYPE'],
108  [/\bMODIFY\s+COLUMN\b|\bALTER\s+TABLE\b[^;]{0,500}?\bMODIFY\s+(?!COLUMN\b)\w/i, 'MODIFY COLUMN'],
109  [/\bRENAME\s+((COLUMN\s+)?\S+\s+)?TO\b|\bRENAME\s+TABLE\b/i, 'RENAME'],
110  // ORM calls get the same names as the SQL they produce, so the question reads the same everywhere.
111  [/\bqueryRunner\.(drop\w*|clearTable|clearDatabase|renameColumn|renameTable|changeColumns?)\b/, (m) => fromMethod(m[1])], // TypeORM
112  // Knex, Laravel, Sequelize, node-pg-migrate, db-migrate, Phinx: table.dropColumn(), $table->dropColumn(), pgm.alterColumn()...
113  [
114    /(?<!queryRunner)(?:\.|::|->)(drop[A-Z]\w*|dropIfExists|remove(?:Column|Index|Constraint|ForeignKey)s?|changeColumn|alterColumn|rename(?:Column|Table))\s*\(/,
115    (m) => fromMethod(m[1]),
116  ],
117  [/\bSchema::drop\s*\(|->drop\(\s*\)/, 'DROP TABLE'], // Laravel, Phinx
118  [/(?:->|\.)(change|alter)\(\s*\)/, 'ALTER COLUMN'], // Laravel ->change(), Knex .alter()
119  [/\b(drop_table|drop_join_table|remove_columns?|remove_timestamps|remove_index|remove_reference|remove_foreign_key|rename_column|rename_table|change_column)\b/, (m) => fromMethod(m[1])], // Rails
120  [/\bt\.(remove|rename)\b/, (m) => (m[1] === 'remove' ? 'DROP COLUMN' : 'RENAME')], // Rails change_table
121  [/\bmigrations\.(RemoveField|DeleteModel|AlterField|RenameField|RenameModel|RemoveIndex|RemoveConstraint)\b/, (m) => fromMethod(m[1])], // Django
122  [/\b\w*op\.(drop_table|drop_column|drop_index|drop_constraint|alter_column|rename_table)\b/, (m) => fromMethod(m[1])], // Alembic, batch_op too
123  [/\bmigrationBuilder\.(Drop\w+|RenameColumn|RenameTable|AlterColumn)\b/, (m) => fromMethod(m[1])], // EF Core
124  [/^\s*(remove(?:_if_exists)?\s+:\w+|drop(?:_if_exists)?\s+(table|index|constraint)\()/m, (m) => (/^\s*remove/.test(m[1] ?? '') ? 'DROP COLUMN' : `DROP ${(m[2] ?? '').toUpperCase()}`)], // Ecto
125  [/<(dropTable|dropColumn|dropIndex|dropForeignKeyConstraint|dropPrimaryKey|dropUniqueConstraint|dropView|dropSequence|renameColumn|renameTable|modifyDataType)\b/, (m) => fromMethod(m[1])], // Liquibase XML
126  [/^\s*-?\s*(dropTable|dropColumn|dropIndex|dropForeignKeyConstraint|renameColumn|renameTable|modifyDataType)\s*:/m, (m) => fromMethod(m[1])], // Liquibase YAML
127  [/\.dropDatabase\s*\(\s*\)/, 'DROP DATABASE'], // Mongo
128  [/\.drop\s*\(\s*\)/, 'DROP COLLECTION'],
129  [/\.deleteMany\s*\(\s*(\{\s*\})?\s*\)|\.deleteFrom\([^)]{0,100}\)\s*\.execute\(|\bknex\(\s*['"][\w.]+['"]\s*\)\s*\.(del|delete)\(\s*\)/, 'DELETE without WHERE'], // Mongo, Prisma, Kysely, Knex
130]
131
132/** Maps an ORM method (dropColumn, remove_column, RemoveField...) to the SQL it stands for. */
133function fromMethod(raw: string | undefined): string {
134  const m = (raw ?? '').replace(/_/g, '').toLowerCase()
135  if (/^(droptable(ifexists)?|dropalltables|dropjointable|dropifexists|deletemodel)$/.test(m)) return 'DROP TABLE'
136  if (m === 'cleardatabase') return 'DROP DATABASE'
137  if (/^(dropcolumns?|removecolumns?|removefield|removetimestamps|drop(timestamps|softdeletes)(tz)?|dropconstrainedforeignid|dropforeignidfor|dropmorphs|dropremembertoken)$/.test(m)) {
138    return 'DROP COLUMN'
139  }
140  if (/^(dropind(ex|exes|ices)|removeindex)$/.test(m)) return 'DROP INDEX'
141  if (/^(drop(foreign(keys?)?(constraint)?|primary(key)?|unique(constraints?)?|check(constraints?)?|exclusion(constraints?)?|constraint)|remove(reference|foreignkey|constraint))$/.test(m)) {
142    return 'DROP CONSTRAINT'
143  }
144  if (/^(cleartable|truncate)$/.test(m)) return 'TRUNCATE'
145  if (/^rename(column|table|field|model)$/.test(m)) return 'RENAME'
146  if (m === 'modifydatatype') return 'ALTER COLUMN TYPE'
147  if (/^(changecolumns?|altercolumn|alterfield)$/.test(m)) return 'ALTER COLUMN'
148  if (m === 'dropmaterializedview') return 'DROP MATERIALIZED VIEW'
149  const object = /^drop(view|schema|database|sequence|function|procedure|trigger|type|extension|policy)$/.exec(m)?.[1]
150  return object ? `DROP ${object.toUpperCase()}` : `${raw ?? 'drop'}()` // named as written, with the generic warning
151}
152
153export type Level = 'high' | 'medium' | 'low'
154
155// One fixed line per kind of change, so the question never guesses.
156const IMPACT: Readonly<Record<string, { level: Level; what: string }>> = {
157  'DROP TABLE': { level: 'high', what: 'deletes the table and every row in it' },
158  'DROP COLLECTION': { level: 'high', what: 'deletes the collection and its documents' },
159  'DROP COLUMN': { level: 'high', what: 'deletes the column and its data' },
160  'DROP DATABASE': { level: 'high', what: 'deletes the whole database' },
161  'DROP SCHEMA': { level: 'high', what: 'deletes the schema and everything in it' },
162  'DROP PARTITION': { level: 'high', what: 'deletes the partition and its rows' },
163  'DROP OWNED': { level: 'high', what: 'deletes everything the role owns' },
164  CASCADE: { level: 'high', what: 'also drops everything that depends on it' },
165  TRUNCATE: { level: 'high', what: 'deletes every row' },
166  'DELETE without WHERE': { level: 'high', what: 'deletes every row' },
167  'UPDATE without WHERE': { level: 'high', what: 'changes every row' },
168  'ALTER COLUMN TYPE': { level: 'medium', what: 'rewrites the column; can lose data and lock the table' },
169  'MODIFY COLUMN': { level: 'medium', what: 'rewrites the column; can lose data and lock the table' },
170  'ALTER COLUMN': { level: 'medium', what: 'changes a column definition; can lock the table or cut data' },
171  RENAME: { level: 'medium', what: 'breaks code that still uses the old name' },
172  'DROP INDEX': { level: 'medium', what: 'can make queries slow' },
173  'DROP CONSTRAINT': { level: 'medium', what: 'lets bad or duplicate data in' },
174  'Empties the file': { level: 'medium', what: 'the migration would do nothing; if it already ran, history can drift' },
175  'Removes code': { level: 'medium', what: 'part of the migration is deleted' }, // edits only, and the edit hint covers "if it already ran"
176}
177
178/** How bad a finding is, in one short line. Unknown kinds get an honest generic line. */
179export function impactOf(label: string): { level: Level; what: string } {
180  return IMPACT[label] ?? { level: 'medium', what: 'removes it, and anything that depends on it breaks' }
181}
182
183const RANK: Readonly<Record<Level, number>> = { high: 2, medium: 1, low: 0 }
184
185/** Worst first, so the question leads with what matters. */
186export function bySeverity(findings: readonly Finding[]): Finding[] {
187  return [...findings].sort((a, b) => RANK[impactOf(b.label).level] - RANK[impactOf(a.label).level])
188}
189
190/** The overall risk: the worst finding, or the command's own risk when nothing was found. */
191export function riskOf(findings: readonly Finding[], commandRisk: Level = 'low'): Level {
192  return findings.reduce<Level>((worst, f) => {
193    const level = impactOf(f.label).level
194    return RANK[level] > RANK[worst] ? level : worst
195  }, commandRisk)
196}
197
198// A WHERE that matches every row is no WHERE at all: WHERE 1=1, WHERE true, MySQL's WHERE 1.
199const REAL_WHERE = /\bWHERE\b(?!\s+(?:1\s*=\s*1|true|1)\s*(?:$|;|\)|LIMIT\b|ORDER\b|RETURNING\b))/i
200
201// Where the forward and undo halves start, written as methods, exports or
202// keys at the start of a line, so a "see down()" inside up() doesn't count.
203const LEAD = String.raw`^[ \t]*(?:(?:public|protected|private|static|export|async|override|void|function|const|let|var)\s+)*`
204const half = (names: string, go: string) =>
205  new RegExp(
206    `${LEAD}(?:${names})\\s*[(:=]|^[ \\t]*func\\s+${go}[A-Z_]\\w*\\s*\\(|^[ \\t]*(?:module\\.)?exports\\.${go}\\b|^[ \\t]*def\\s+(?:${names})\\b`,
207    'm',
208  )
209const UP = half('up|upgrade|change|Up', 'up')
210const DOWN = half('down|downgrade|Down', 'down')
211// SQL files keep both halves in one file, split by a comment: goose, dbmate, sql-migrate, MyBatis, Play.
212const SQL_DOWN_MARKER = /^[ \t]*(?:--[ \t]*(?:\+goose[ \t]+Down|migrate:down|\+migrate[ \t]+Down|\/\/@UNDO)|#[ \t]*---[ \t]*!Downs)\b/im
213// Django's RunSQL(..., reverse_sql=...) is the undo half inside the forward one.
214const REVERSE_SQL = /\breverse_sql\s*=\s*(\[[^\]]{0,5000}\]|"""[\s\S]{0,5000}?"""|'''[\s\S]{0,5000}?'''|"[^"\n]{0,5000}"|'[^'\n]{0,5000}')/g
215
216/** The part of a migration that runs forward: everything before down() when up() comes first. */
217function forwardPart(text: string): string {
218  const up = text.search(UP)
219  const down = text.search(DOWN)
220  return (up >= 0 && down > up ? text.slice(0, down) : text).replace(REVERSE_SQL, '')
221}
222
223/**
224 * `-- DROP TABLE users` in a comment is not a drop. A block comment never
225 * spans a `;`, so a `/*` inside a string can't swallow the statements after it.
226 */
227function stripComments(text: string, hash: boolean): string {
228  const out = text
229    .replace(/\/\*(?:(?!\*\/|\/\*)[^;])*\*\//g, ' ')
230    .replace(/(^|\s)--\s[^\n]*/g, '$1')
231    .replace(/(^|\s)\/\/[^\n]*/g, '$1')
232  return hash ? out.replace(/(^|\s)#[^\n]*/g, '$1') : out
233}
234
235/** Python, Ruby, Elixir and YAML comment with #. */
236export function usesHashComments(path: string): boolean {
237  return /\.(py|rb|exs?|ya?ml)$/i.test(path)
238}
239
240/** Nothing left once comments and blank lines are gone: the migration does nothing. */
241export function isEffectivelyEmpty(text: string, path: string): boolean {
242  return stripComments(capped(text), usesHashComments(path)).trim() === ''
243}
244
245// Statements that build something. Fewer of them after an edit means part of the migration is gone.
246const BUILDS =
247  /\bCREATE\s+(UNIQUE\s+)?(INDEX|TABLE|VIEW|TRIGGER|FUNCTION|TYPE|SEQUENCE|POLICY)\b|\bADD\s+(COLUMN|CONSTRAINT)\b|\b(createIndex|createTable|addColumn|addIndex|addForeignKey|add_index|add_column|add_reference|create_table|create_index|AddField|CreateModel|AddIndex|AddConstraint|CreateTable|AddColumn|CreateIndex)\b/gi
248
249/** The first building statement the edit takes away, if it takes any away. */
250export function removedBuild(before: string, after: string): string | undefined {
251  const count = (text: string) => (capped(text).match(BUILDS) ?? []).length
252  if (count(after) >= count(before)) return undefined
253  const kept = new Set(after.split('\n').map((l) => l.trim()))
254  return before.split('\n').find((l) => new RegExp(BUILDS.source, 'i').test(l) && !kept.has(l.trim()))?.trim().slice(0, 80) ?? ''
255}
256
257function snippetAt(text: string, index: number): string {
258  const start = text.lastIndexOf('\n', index) + 1
259  const end = text.indexOf('\n', index)
260  const line = text.slice(start, end < 0 ? undefined : end).trim()
261  return line.length > 80 ? `${line.slice(0, 77)}...` : line
262}
263
264/**
265 * Statements that can lose data or break running code. With `skipDown`, a
266 * whole migration file is read up to its down()/downgrade, because the undo
267 * half always drops what up() created and would cry wolf every time.
268 * Commands keep their comments: a glob like `/*` would otherwise hide SQL.
269 */
270export function findDestructive(
271  text: string,
272  opts: { skipDown?: boolean; keepComments?: boolean; hashComments?: boolean } = {},
273): Finding[] {
274  let body = capped(text)
275  if (opts.skipDown) {
276    const marker = body.search(SQL_DOWN_MARKER)
277    if (marker >= 0) body = body.slice(0, marker)
278  }
279  if (!opts.keepComments) body = stripComments(body, opts.hashComments === true)
280  if (opts.skipDown) body = forwardPart(body)
281
282  const found: Finding[] = []
283  const seen = new Set<string>()
284  const add = (label: string, index: number) => {
285    if (seen.has(label)) return
286    seen.add(label)
287    found.push({ label, snippet: snippetAt(body, index) })
288  }
289
290  // Every match, not just the first: dropIndex then dropTable must still report DROP TABLE.
291  for (const [re, label] of RULES) {
292    const all = new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`)
293    let count = 0
294    for (const m of body.matchAll(all)) {
295      add(typeof label === 'string' ? label : label(m), m.index ?? 0)
296      if (++count >= 50) break
297    }
298  }
299
300  let offset = 0
301  for (const statement of body.split(';')) {
302    const del = /\bDELETE\s+FROM\b/i.exec(statement)
303    if (del && !REAL_WHERE.test(statement)) add('DELETE without WHERE', offset + del.index)
304    const upd = /\bUPDATE\s+(?:ONLY\s+)?["`\w.[\]]+(?:\s+(?:AS\s+)?\w+)?\s+SET\b/i.exec(statement)
305    if (upd && !REAL_WHERE.test(statement)) add('UPDATE without WHERE', offset + upd.index)
306    offset += statement.length + 1
307  }
308
309  return found
310}
311
312/** A cheap "could this be about migrations?" test, for failing closed when the guard itself breaks. */
313export function looksMigrationRelated(text: string): boolean {
314  return /migrat|alembic|changelog|db:(migrate|rollback|drop|reset)|\bDROP\s|\bTRUNCATE\b/i.test(capped(text))
315}
316
317// ------------------------------------------------------- reading commands
318
319type Step = { text: string; substitutes: boolean }
320
321/**
322 * Splits a command line into steps on && || ; & and newlines (and on | when
323 * asked), never inside quotes. `substitutes` notes $( ) or backticks, which
324 * run commands even inside an echo.
325 */
326function splitSteps(command: string, pipes: boolean, powershell: boolean): Step[] {
327  const escape = powershell ? '`' : '\\'
328  const steps: Step[] = []
329  let quote = ''
330  let start = 0
331  let substitutes = false
332  const cut = (end: number) => {
333    const text = command.slice(start, end)
334    if (text.trim() !== '') steps.push({ text, substitutes })
335    substitutes = false
336  }
337  for (let i = 0; i < command.length; i++) {
338    const c = command[i]
339    const after = command[i + 1] ?? ''
340    if (quote === "'") {
341      if (c === "'") quote = ''
342      continue
343    }
344    if (c === escape) {
345      i++
346      continue
347    }
348    if (!powershell && (c === '`' || (c === '$' && after === '('))) substitutes = true
349    if (powershell && c === '$' && after === '(') substitutes = true
350    if (quote === '"') {
351      if (c === '"') quote = ''
352      continue
353    }
354    if (c === '"' || c === "'") {
355      quote = c
356      continue
357    }
358    let width = 0
359    if (c === '\n' || c === ';') width = 1
360    else if ((c === '&' || c === '|') && after === c) width = 2
361    else if (c === '|' && pipes) width = 1
362    else if (c === '&' && after !== '>' && command[i - 1] !== '>') width = 1
363    if (width > 0) {
364      cut(i)
365      start = i + width
366      i += width - 1
367    }
368  }
369  cut(command.length)
370  return steps
371}
372
373// Who reads a heredoc decides what its body is: text (a commit message), a
374// script (python, node...), or more shell (bash, ssh, a database client).
375const TEXT_READER = /\b(cat|git|gh|glab|echo|printf|tee|less|more|head|tail|wc|jq|yq)\b/
376const SCRIPT_READER = /\b(python[\d.]*|node|deno|bun|ruby|perl|php|tsx|ts-node)\b/
377const SHELL_READER = /\b(bash|sh|zsh|dash|ksh|fish|pwsh|powershell|xargs|eval|source|docker|podman|kubectl|ssh|patch|git\s+(apply|am))\b/
378// A patch says which files it changes in its body, not on the command line.
379const APPLIES_PATCH = /\bgit\s+(apply|am)\b|\bpatch\s+(-p\d|-i\b|<)/
380const HEREDOC = /<<(-?)[ \t]*(['"]?)([A-Za-z_][\w.-]*)\2/
381
382/**
383 * Takes heredoc bodies out of the command line, so a commit message that
384 * says "npm run migration:run" isn't a command. A body fed to a shell or a
385 * database client stays, since it is more commands. A body fed to python,
386 * node and the like goes to `scripts`, judged as code. A body written into a
387 * migration (cat > migrations/x.sql <<EOF) goes to `written`, so the question
388 * can say what's in it.
389 */
390function readHeredocs(command: string, extraPath?: RegExp): { text: string; written: string[]; scripts: string[] } {
391  const lines = command.split('\n')
392  const kept: string[] = []
393  const written: string[] = []
394  const scripts: string[] = []
395  let seen = 0
396  for (let i = 0; i < lines.length; i++) {
397    const line = lines[i] ?? ''
398    kept.push(line)
399    const m = HEREDOC.exec(line)
400    if (!m || insideQuotes(line, m.index) || ++seen > 20) continue
401    let end = -1
402    for (let j = i + 1; j < lines.length; j++) {
403      if ((m[1] === '-' ? (lines[j] ?? '').replace(/^\t+/, '') : (lines[j] ?? '')).replace(/\r$/, '') === m[3]) {
404        end = j
405        break
406      }
407    }
408    if (end < 0) continue // never closed: not a heredoc we understand, keep everything
409    if (SHELL_READER.test(line) || DB_CLIENT.test(line)) continue
410    const body = lines.slice(i + 1, end).join('\n')
411    if (SCRIPT_READER.test(line)) scripts.push(body)
412    else if (!TEXT_READER.test(line)) continue
413    else if (writesMigration(line, extraPath)) written.push(body)
414    i = end - 1 // skip the body; the closing line is kept and is harmless
415  }
416  return { text: kept.join('\n'), written, scripts }
417}
418
419/** Whether `index` sits inside quotes. `$(` starts fresh quoting, as in "$(cat <<'EOF' ...)". */
420function insideQuotes(line: string, index: number): boolean {
421  const open: string[] = []
422  for (let i = 0; i < index; i++) {
423    const c = line[i]
424    const top = open[open.length - 1]
425    if (top === "'") {
426      if (c === "'") open.pop()
427    } else if (c === '\\') i++
428    else if (c === '$' && line[i + 1] === '(') {
429      open.push('(')
430      i++
431    } else if (top === '"') {
432      if (c === '"') open.pop()
433    } else if (c === ')' && top === '(') open.pop()
434    else if (c === '"' || c === "'") open.push(c)
435  }
436  const top = open[open.length - 1]
437  return top === '"' || top === "'"
438}
439
440// Commands that only print, search or read: mentioning "migration:run" in them runs nothing,
441// and a pipeline made only of them (grep ... | cut ... | head) changes nothing.
442const ONLY_MENTIONS =
443  /^\s*(echo|printf|grep|egrep|fgrep|rg|ag|ack|findstr|cat|bat|head|tail|cut|sort|uniq|wc|tr|column|nl|less|more|jq|yq|ls|tree|stat|file|diff|cmp|xargs\s+(?:-\S+\s+)*(?:grep|egrep|rg|cat|head|tail|wc|ls|file|stat)|Write-(Output|Host)|Select-String|Get-Content|Get-ChildItem|git\s+(commit|log|show|diff|grep|tag|status|blame|ls-files)|gh\s+(pr|issue)\s+(create|comment|edit|view))\b/i
444
445function onlyMentions(step: Step, powershell: boolean): boolean {
446  return !step.substitutes && splitSteps(step.text, true, powershell).every((part) => ONLY_MENTIONS.test(part.text))
447}
448
449// ------------------------------------------------------------- commands
450
451// Commands that only look, plan, or write a new migration file.
452const READ_ONLY =
453  /\b(migration:(show|status|generate|create)|migrate:(status|make|install)|migrate\s+(status|create|new|make|list)|showmigrations|alembic\s+(current|history|heads|check|show|branches|revision)|prisma\s+migrate\s+(status|diff)|ef\s+migrations\s+(list|add|script)|(update|rollback)-?sql)\b|--create-only\b|\bmigrate\b[^|;&\n]{0,200}?\s--(plan|check)\b/i
454
455const WIPE_WORD = /\b(reset|fresh|refresh|drop|purge|flush|wipe|clean|truncate|destroy|nuke|replant|reset_db|truncate_all|dropAll|flywayClean)\b/i
456const UNDO_WORD = /\b(revert|rollback|undo|down|downgrade|redo|zero|remove|update\s+0|VERSION=0)\b/i
457const PUSH_WORD = /\b(push|sync)\b/i
458const FORCE_WORD = /\bforce\b/i
459const HISTORY_WORD = /\b(stamp|resolve|repair|fake|baseline|changelog-?sync|mark)\b/i
460
461/** How risky a migration command is by its own words, and the line that says why. */
462function riskOfWords(text: string): { risky: boolean; note: string } {
463  if (WIPE_WORD.test(text)) return { risky: true, note: NOTE.wipes }
464  if (UNDO_WORD.test(text)) return { risky: true, note: NOTE.undoes }
465  if (PUSH_WORD.test(text)) return { risky: true, note: NOTE.pushes }
466  if (FORCE_WORD.test(text)) return { risky: true, note: NOTE.forced }
467  if (HISTORY_WORD.test(text)) return { risky: false, note: NOTE.history }
468  return { risky: false, note: NOTE.applies }
469}
470
471// Flags with an optional value, as package managers and make take them before the script: --filter api, -C apps/api
472const FLAGS = String.raw`(?:--?[\w-]+(?:[=\s][^\s-]\S*)?\s+)`
473
474// Bounded gaps ([^...]{0,200}) instead of .* keep every rule linear on long input.
475const RUNNERS: ReadonlyArray<readonly [RegExp, string]> = [
476  // Package scripts, workspace flags included: pnpm --filter api db:migrate, npm --prefix api run migrate
477  [
478    new RegExp(
479      String.raw`\b(npm|pnpm|yarn|bun)(\.cmd|\.exe)?\s+(?:${FLAGS}|workspace\s+\S+\s+|run(?:-script)?\s+){0,6}(?!test|lint|check|verify|typecheck|format)[\w:.-]*(migrat|db[-_:]?(reset|drop|rollback|push|wipe))[\w:.-]*`,
480      'i',
481    ),
482    'a package script that migrates',
483  ],
484  [/\bturbo\s+(?:\S+\s+){0,5}?\S*migrat|\bnx\s+(run|run-many|affected)\b[^|;&\n]{0,200}?[\s:]migrat|\blerna\s+run\s+\S*migrat/i, 'a monorepo task that migrates'],
485  [
486    new RegExp(String.raw`\b(make|just|task|mage)(\.exe)?\s+${FLAGS}{0,4}[\w:./-]*(migrat|db[-_:]?(reset|drop|rollback|push))[\w:./-]*`, 'i'),
487    'a make/just/task target that migrates',
488  ],
489  [/\b(docker|podman)[- ]compose\b[^|;&\n]{0,200}?\b(run|up)\b[^|;&\n]{0,200}?\bmigrat\w*/i, 'a container that migrates'],
490  [/\btypeorm(-ts-node-\w+)?\s+(migration:(run|revert)|schema:(drop|sync))\b/i, 'typeorm'],
491  // Any CLI driven by a migrate:/migration: verb, e.g. `node node_modules/typeorm/cli.js migration:run`.
492  [/\bmigrat(e|ion):(run|revert|up|down|latest|rollback|reset|fresh|refresh)\b/i, 'migrations'],
493  [/\bschema:(drop|sync|update|fresh)\b/i, 'a schema sync'],
494  [/\bprisma\s+(migrate\s+(dev|deploy|reset|resolve)|db\s+push)\b/i, 'prisma'],
495  [/\bsequelize(-cli)?\s+db:(migrate|drop|seed:undo)/i, 'sequelize'],
496  [/\b(rails|rake)\s+db:(migrate|rollback|drop|reset|setup|prepare|schema:load|purge|seed:replant|truncate_all)\b/i, 'a rails db task'],
497  [/\balembic\b[^|;&\n]{0,200}?\b(upgrade|downgrade|stamp)\b/i, 'alembic'],
498  [/\bflask\s+db\s+(upgrade|downgrade|stamp)\b/i, 'flask-migrate'],
499  [/\b(manage\.py|django-admin)\s+(migrate|flush|reset_db)\b/i, 'django migrate'],
500  [/\bartisan\s+(migrate|db:wipe)\b/i, 'laravel migrate'],
501  [/\bdoctrine:(migrations:(migrate|execute|rollup|version)|schema:(update|drop))\b/i, 'doctrine'],
502  [/\bphinx\s+(migrate|rollback)\b|\byii\s+migrate(?!\/(create|history|new))\b|\bcake\s+migrations\s+(migrate|rollback)\b/i, 'php migrations'],
503  [/\bflyway\b[^|;&\n]{0,200}?\b(migrate|clean|repair|undo|baseline)\b|\bflyway(Migrate|Clean|Repair)\b/i, 'flyway'],
504  [/\bliquibase\b[^|;&\n]{0,200}?\b(update|rollback|drop-?all|changelog-?sync)\b/i, 'liquibase'],
505  [/\bdrizzle-kit\s+(push|migrate)\b/i, 'drizzle-kit'],
506  [/\bsupabase\s+(db\s+(push|reset)|migration\s+(up|repair|squash))\b/i, 'supabase'],
507  [/\bwrangler\s+d1\s+migrations\s+apply\b/i, 'wrangler d1'],
508  [/\bhasura\s+(migrate\s+(apply|delete)|metadata\s+apply)\b/i, 'hasura'],
509  [/\b(node-pg-migrate|db-migrate)\s+(up|down|redo|reset)\b|\bsqitch\s+(deploy|revert|rebase)\b/i, 'migrations'],
510  [/\b(goose|dbmate)\b[^|;&\n]{0,200}?\b(up|up-by-one|up-to|down|down-to|reset|redo|rollback|drop|migrate)\b/i, 'goose/dbmate'],
511  [/\bmigrate\b[^|;&\n]{0,200}?\s-(path|source|database)\b[^|;&\n]{0,200}?\b(up|down|drop|force|goto)\b/i, 'golang-migrate'],
512  [/\batlas\s+(migrate\s+apply|schema\s+(apply|clean))\b/i, 'atlas'],
513  [/\bdotnet\s+ef\s+(database\s+(update|drop)|migrations\s+remove)\b|\b(Update|Drop)-Database\b/i, 'EF Core'],
514  [/\bdiesel\s+(migration\s+(run|revert|redo)|database\s+reset)\b/i, 'diesel'],
515  [/\bsqlx\s+(migrate\s+(run|revert)|database\s+(drop|reset))\b/i, 'sqlx'],
516  [/\bmix\s+ecto\.(migrate|rollback|drop|reset)\b/i, 'ecto'],
517  // Hand-rolled ones: node scripts/migrate.ts, python migrate.py, go run ./cmd/migrate up
518  [
519    /\b(node|tsx|ts-node|bun|deno\s+run|python[\d.]*|ruby|php)\b[^|;&\n]{0,200}?(^|[\s'"\\/])(run[-_])?(migrate|migrations?|migrator)([-_](up|down|latest|all))?\.(m?[jt]s|py|rb|php)\b|\bgo\s+run\s+\S*cmd[\\/]migrate\b/i,
520    'a migration script',
521  ],
522]
523
524// Database clients as commands, not as part of a path or a file name (bitnami/mysql is a chart, mysql.yaml a file).
525const DB_CLIENT =
526  /(?<![\w.-])(psql|pgcli|mysql|mycli|mariadb|sqlite3|litecli|sqlcmd|Invoke-Sqlcmd|sqlplus|mongosh|mongo|clickhouse-client|cockroach\s+sql|duckdb|usql|turso\s+db\s+shell|pscale\s+shell|wrangler\s+d1\s+execute|prisma\s+db\s+execute|typeorm(-ts-node-\w+)?\s+query|manage\.py\s+dbshell|rails\s+(dbconsole|db)(?=\s|$)|artisan\s+db(?=\s|$))(?![\w./-])/i
527// Text after the client that feeds it SQL we can't see: -f x, --file x, < x, \i x, .read x, source x.sql.
528// Case matters: psql's -F is a field separator. A -f with a config file is some other tool's flag.
529const SQL_FILE_INPUT =
530  /\s-f\s*(?!\S*\.(?:ya?ml|json|toml|conf|ini|env|cfg)\b)\S|\s--file[=\s]\S|(?<!<)<(?!<)\s*[^\s<]+|\s-i\s+\S|\\i\s+\S|\\include\s+\S|\.read\s+\S|--stdin\b|\.[sS][qQ][lL]\b|\bsource\s+\S/
531// cat x.sql | psql, gunzip -c dump.sql.gz | psql
532const SQL_PIPED_IN = /\.sql(\.(gz|bz2|xz|zst))?\b["']?\s*\|/i
533
534const DATA_WIPES: ReadonlyArray<readonly [RegExp, string, string]> = [
535  [/\b(docker|podman)[- ]compose\b[^|;&\n]{0,200}?\bdown\b[^|;&\n]{0,200}?\s(-v|--volumes)\b/i, 'delete Docker volumes', NOTE.volumes],
536  [/\b(docker|podman)\s+(volume\s+(rm|prune)|system\s+prune\b[^|;&\n]{0,200}?--volumes)\b/i, 'delete Docker volumes', NOTE.volumes],
537  [/\bdropdb\b|\bmysqladmin\b[^|;&\n]{0,200}?\bdrop\b/i, 'drop a database', NOTE.dropDb],
538  [/\bheroku\s+pg:reset\b/i, 'reset a Heroku database', NOTE.dropDb],
539]
540
541const MIGRATION_DIR_WORD =
542  /(^|[\s'"=\\/*(,])(migrations?|db[\\/]migrate|db[\\/]changelog|alembic[\\/]versions|drizzle(?=[\\/]))(?=$|[\s'"\\/*),])/i
543// The folder has to be a whole path segment: cd tools/migration-guard is not cd migrations.
544const CD_INTO_MIGRATIONS =
545  /^\s*(cd|pushd|Set-Location|Push-Location|sl)\s+["']?(?:\S{0,300}[\\/])?(migrations?|db[\\/]migrate|db[\\/]changelog|alembic[\\/]versions|drizzle)(?=$|[\\/\s'"])/i
546const CD = /^\s*(cd|pushd|popd|Set-Location|Push-Location|Pop-Location|sl)\b/i
547const REDIRECT = /(?:>{1,2}|\btee\s+(?:-a\s+)?)\s*(\S+)/g
548
549/** A redirect (`>`, `>>`, `tee`) whose target is a migration file. A README or a test next to it doesn't count. */
550function writesMigration(text: string, extraPath?: RegExp): boolean {
551  for (const m of capped(text).matchAll(REDIRECT)) {
552    if (isMigrationPath((m[1] ?? '').replace(/["']/g, ''), extraPath)) return true
553  }
554  return false
555}
556
557// A short command name only counts where a command starts: `grep -rni` is not PowerShell's `rni`.
558const AT_COMMAND = String.raw`(?:^\s*|[|({\x60]\s*|\$\(\s*|\bxargs(?:\s+-\S+)*\s+|-exec(?:dir)?\s+|\b(?:sudo|time|nohup|command|env)\s+(?:-\S+\s+)*|\b\w+=\S*\s+)`
559const IN_PLACE = /\b(sed|perl)\b[^|;&\n]{0,300}?\s(-\w*i|--in-place)/i
560// Unix, Windows (cmd, PowerShell) ways of changing, moving or deleting files, other than editing in place.
561const OTHER_FILE_VERB = new RegExp(
562  [
563    `${AT_COMMAND}(rm|rmdir|mv|truncate|unlink|shred|del|erase|rd|ren|rename|move|ri|ni|mi|rni|touch|unzip)(?=\\s|$)`,
564    `${AT_COMMAND}(tar\\s+-?\\w*x|tar\\b[^|;&\\n]{0,200}--extract|dd\\b[^|;&\\n]{0,300}?\\bof=|(curl|wget)\\b[^|;&\\n]{0,300}?\\s-[oO]\\b)`,
565    String.raw`\bgit\s+(checkout(?!\s+-[bBt]\b)|restore(?![^|;&\n]{0,200}--staged\b)|rm|mv|clean|apply|stash)\b`,
566    String.raw`\b(Remove|Move|Rename|New|Clear)-Item\b|\b(Set|Add|Clear)-Content\b|\bOut-File\b`,
567    String.raw`\s-delete\b`,
568  ].join('|'),
569  'i',
570)
571const FILE_VERB = new RegExp(`${IN_PLACE.source}|${OTHER_FILE_VERB.source}`, 'i')
572
573/**
574 * The step without the script that sed or perl runs. In `sed -i 's/a migration/b/' notes.txt`
575 * the quoted part is code and only notes.txt changes, so only notes.txt (and anything piped in,
576 * as in `find migrations | xargs sed -i ...`) should say where the edit lands.
577 */
578function withoutEditScript(text: string): string {
579  // The sed or perl that edits in place, not just the first "sed" in the line (./sed.txt).
580  const m = IN_PLACE.exec(text)
581  if (!m) return text
582  const name = m[1] ?? ''
583  const sed = name.toLowerCase() === 'sed'
584  const head = m.index + name.length
585  const words: Array<{ raw: string; value: string }> = []
586  let i = head
587  let end = text.length
588  while (i < text.length) {
589    while (i < text.length && /\s/.test(text.charAt(i))) i++
590    if (i >= text.length) break
591    if (/[|;&]/.test(text.charAt(i))) {
592      end = i
593      break
594    }
595    let raw = ''
596    let value = ''
597    while (i < text.length && !/[\s|;&]/.test(text.charAt(i))) {
598      const ch = text.charAt(i)
599      if (ch === "'" || ch === '"') {
600        let j = i + 1
601        while (j < text.length && text.charAt(j) !== ch) j += ch === '"' && text.charAt(j) === '\\' ? 2 : 1
602        value += text.slice(i + 1, j)
603        raw += text.slice(i, j + 1)
604        i = j + 1
605      } else if (ch === '\\' && i + 1 < text.length) {
606        value += text.charAt(i + 1)
607        raw += text.slice(i, i + 2)
608        i += 2
609      } else {
610        value += ch
611        raw += ch
612        i++
613      }
614    }
615    words.push({ raw, value })
616  }
617
618  const drop = new Set<number>()
619  let script = false
620  let options = true
621  for (let k = 0; k < words.length; k++) {
622    const w = words[k]?.value ?? ''
623    if (options && w === '--') {
624      options = false
625    } else if (options && /^--(expression|file)$/.test(w)) {
626      drop.add(k + 1)
627      k++
628      script = true
629    } else if (options && /^--(expression|file)=/.test(w)) {
630      drop.add(k)
631      script = true
632    } else if (options && /^-[^-]/.test(w)) {
633      // A bundle like -pi, -ne, -i.bak. After i comes a backup suffix; after e (or f for sed) the script.
634      for (let c = 1; c < w.length; c++) {
635        const flag = w.charAt(c)
636        if (flag === 'i') {
637          // BSD sed takes the suffix as its own word: sed -i '' 's/a/b/' x
638          if (sed && c === w.length - 1 && w.length === 2 && (words[k + 1]?.value === '' || words[k + 1]?.value.startsWith('.'))) {
639            drop.add(k + 1)
640            k++
641          }
642          break
643        }
644        // sed -E is extended regexps; perl -E is -e with features on.
645        if (flag === 'e' || (flag === 'E' && !sed) || (flag === 'f' && sed)) {
646          if (c < w.length - 1) drop.add(k)
647          else {
648            drop.add(k + 1)
649            k++
650          }
651          script = true
652          break
653        }
654        if (/[lI0MmFCdDx]/.test(flag)) break // takes the rest of the bundle as its value
655      }
656    } else if (!script) {
657      // No -e: sed's first word is its script, perl's is a program file. Neither is a file it edits.
658      drop.add(k)
659      script = true
660      options = false
661    }
662  }
663  const kept = words.filter((_, k) => !drop.has(k)).map((w) => w.raw)
664  return `${text.slice(0, head)} ${kept.join(' ')} ${text.slice(end)}`
665}
666// Copies only change migrations when they land in a migrations folder: cp migrations/x.sql /tmp/ is a read.
667const COPY_VERB = new RegExp(`${AT_COMMAND}(cp|copy|cpi|rsync|install|ln)(?=\\s|$)|\\bCopy-Item\\b`, 'i')
668const DELETES = new RegExp(
669  `${AT_COMMAND}(rm|rmdir|del|erase|rd|ri|unlink|shred|truncate)(?=\\s|$)|\\bgit\\s+(rm|clean)\\b|\\b(Remove-Item|Clear-Content)\\b|\\s-delete\\b`,
670  'i',
671)
672// Steps whose quoted text is code that runs (bash -c "...", ssh host "..."), not text to ignore.
673const RUNS_QUOTED = /\b(bash|sh|zsh|dash|ksh|fish|pwsh|powershell|cmd(\.exe)?|ssh|eval|su|docker\s+(exec|run)|podman\s+(exec|run)|kubectl\s+exec)\b/i
674// One-liners: python -c "...", node -e "...", ruby -e, php -r, deno eval
675const ONE_LINER = /^\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(python[\d.]*|node|deno|bun|ruby|perl|php|tsx)\b[^|;&\n]{0,100}?\s(-c|-e|-r|-p|--eval|eval)\s/i
676
677/** The step with quoted text blanked out: `grep "TRUNCATE" migrations/` searches, it doesn't truncate. */
678function unquoted(text: string): string {
679  return text.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, '""')
680}
681
682/** The last word of a step, without quotes: where cp, rsync and friends put things. */
683function destination(text: string): string {
684  return (text.trim().split(/\s+/).pop() ?? '').replace(/^["']|["']$/g, '')
685}
686
687/** A shell change to migration files. Deleting is high risk; writing is judged by what's written. */
688function shellChange(step: Step, written: readonly string[]): CommandHit {
689  const findings = dedupe([
690    ...findDestructive(step.text, { keepComments: true }),
691    ...written.flatMap((body) => findDestructive(body, { skipDown: true })),
692  ])
693  const deletes = DELETES.test(unquoted(step.text))
694  const risky = deletes || riskOf(findings) === 'high'
695  // `cat > migrations/x.sql <<'EOF'` reads better without the heredoc marker.
696  const part = step.text.trim().replace(/\s*<<-?\s*(['"]?)[\w.-]+\1\s*$/, '')
697  return { reason: 'change migration files from the shell', risky, note: deletes ? NOTE.shellDeletes : NOTE.shellWrites, findings, part }
698}
699
700// ------------------------------------------------------------- scripts
701
702// What scripts call to write, move or delete files, and the path arguments they take:
703// open(p, 'w'), fs.writeFileSync(p, ...), os.remove(p), shutil.move(a, b), Path(p).write_text(...), p.unlink()
704const WRITE_CALL =
705  /\bopen\(\s*([^,)]{1,300}?)\s*,\s*[rf]?['"][wax]|\b(?:writeFile(?:Sync)?|appendFile(?:Sync)?|unlink(?:Sync)?|rmSync|rmdirSync|renameSync|copyFileSync|createWriteStream|fs\.(?:rm|unlink|rename|writeFile|appendFile|copyFile)|os\.(?:remove|unlink|rename|replace|rmdir)|shutil\.(?:rmtree|move|copy\w*)|FileUtils\.(?:rm\w*|mv|cp)|File\.(?:write|delete|rename))\s*\(([^)]{0,300})\)|\bPath\(\s*([^)]{1,300}?)\s*\)\s*\.(?:write_text|write_bytes|unlink|rename|replace|rmdir)\b|\b(\w+)\.(?:write_text|write_bytes|unlink)\s*\(/g
706const SCRIPT_DELETE = /\b(unlink(Sync)?|rmSync|rmdirSync|os\.(remove|unlink|rmdir)|shutil\.rmtree|FileUtils\.rm\w*|File\.delete|fs\.(rm|unlink))\b/
707const SCRIPT_SPAWNS = /\b(subprocess\.\w+|os\.system|os\.popen|execSync|execFileSync|spawnSync|exec|spawn|system|popen)\s*\(/g
708const SCRIPT_QUERIES = /\.(execute|executescript|query|exec|raw)\s*\(/g
709const LITERAL = /'''([\s\S]{0,20000}?)'''|"""([\s\S]{0,20000}?)"""|'((?:[^'\\\n]|\\.){0,5000})'|"((?:[^"\\\n]|\\.){0,5000})"|`([^`]{0,20000})`/g
710// A migrations folder, or a folder inside one: 'migrations', 'src/database/migrations/index/'
711const MIGRATION_DIR_ONLY = /(^|[\\/])(migrations?|db[\\/]migrate|db[\\/]changelog|alembic[\\/]versions|drizzle)([\\/][^.]*)?$/i
712
713/** The code a one-liner runs: the quoted argument after -c or -e. */
714function oneLinerCode(text: string): string {
715  return /\s(?:-c|-e|-r|-p|--eval|eval)\s+(['"])((?:(?!\1)[^\\]|\\[\s\S]){0,20000})\1/.exec(text)?.[2] ?? text
716}
717
718function stringLiterals(code: string): string[] {
719  const out: string[] = []
720  for (const m of capped(code).matchAll(LITERAL)) {
721    out.push(m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5] ?? '')
722    if (out.length >= 2000) break
723  }
724  return out
725}
726
727/** Where the script's longer strings sit, so a call written inside a string (text being inserted) doesn't count. */
728function stringRanges(code: string): Array<readonly [number, number]> {
729  const out: Array<readonly [number, number]> = []
730  for (const m of code.matchAll(LITERAL)) {
731    if (m[0].length > 6) out.push([m.index ?? 0, (m.index ?? 0) + m[0].length])
732    if (out.length >= 2000) break
733  }
734  return out
735}
736
737function insideAny(ranges: ReadonlyArray<readonly [number, number]>, index: number): boolean {
738  return ranges.some(([start, end]) => index > start && index < end)
739}
740
741/**
742 * What a path argument stands for: a string ('x.ts', f'...{n}.ts'), a variable
743 * assigned a string somewhere in the script (p = 'x.ts'), the first argument
744 * at every call of a helper whose parameter it is (def edit(p, ...) called as
745 * edit('x.ts', ...)), or the strings in an expression
746 * (os.path.join(ROOT, 'migrations', name)). `sure` is false when the script
747 * builds the path in a way we can't follow.
748 */
749function pathValues(expr: string, code: string): { values: string[]; sure: boolean } {
750  const e = expr.trim()
751  const literal = /^[rfb]?(['"`])([\s\S]*)\1$/.exec(e)
752  if (literal) return { values: [literal[2] ?? ''], sure: true }
753  if (/^\w+$/.test(e)) {
754    const assigned = new RegExp(String.raw`(?:^|[\s;(,])(?:const\s+|let\s+|var\s+)?${e}\s*=\s*(?:Path\(\s*)?[rf]?(['"\x60])([^'"\x60\n]{0,500})\1`, 'm').exec(code)
755    if (assigned) return { values: [assigned[2] ?? ''], sure: true }
756    // A helper's first parameter: look at what each call passes.
757    const helper = new RegExp(String.raw`\b(?:def|function)\s+(\w+)\s*\(\s*${e}\b`).exec(code)?.[1]
758    if (helper) {
759      const passed = [...code.matchAll(new RegExp(String.raw`(?<!def |function )\b${helper}\(\s*([^,)]{1,300})`, 'g'))].slice(0, 50)
760      const each = passed.map((m) => pathValues(m[1] ?? '', code.replace(new RegExp(String.raw`\b(?:def|function)\s+${helper}\b`, 'g'), '')))
761      return { values: each.flatMap((p) => p.values), sure: passed.length > 0 && each.every((p) => p.sure) }
762    }
763    return { values: [], sure: false }
764  }
765  return { values: stringLiterals(e), sure: false }
766}
767
768/**
769 * A script (python -c, node -e, a heredoc fed to python) that writes or
770 * deletes a migration file, runs migrations, or runs destructive SQL. A write
771 * counts when its target is a migration path; when the target can't be
772 * followed, when any string in the script is one. So a script that edits a
773 * doc which merely mentions migrations/ stays quiet.
774 */
775function scriptChange(code: string, extraPath: RegExp | undefined, insideMigrations: boolean): CommandHit | undefined {
776  code = capped(code)
777  const literals = stringLiterals(code)
778  const ranges = stringRanges(code)
779  // After a cd into a migrations folder, a bare file name ('001.sql') is a migration too.
780  const isTarget = (l: string) =>
781    !/[*?]/.test(l) && (isMigrationPath(l, extraPath) || MIGRATION_DIR_ONLY.test(l) || (insideMigrations && CODE_FILE.test(l) && !/[\\/\s]/.test(l)))
782
783  let writes = false
784  let hit: string | undefined // the migration it writes, named in the question
785  let unsure = false
786  let looked = 0
787  const resolved = new Map<string, { values: string[]; sure: boolean }>()
788  for (const m of code.matchAll(WRITE_CALL)) {
789    if (hit !== undefined) break
790    if (++looked > 50) {
791      unsure = true // real scripts write a handful of files; past that, fall back to any migration path in it
792      break
793    }
794    if (insideAny(ranges, m.index ?? 0)) continue
795    writes = true
796    const args = m[1] ?? m[3] ?? m[4] ?? (m[2] ?? '').split(',').slice(0, 2).join('\u0000')
797    for (const arg of args.split('\u0000')) {
798      const key = arg.trim()
799      const found = resolved.get(key) ?? pathValues(key, code)
800      resolved.set(key, found)
801      hit ??= found.values.find(isTarget)
802      if (!found.sure) unsure = true
803    }
804  }
805  if (writes && unsure) hit ??= literals.find(isTarget)
806  if (writes && hit !== undefined) {
807    const findings = findDestructive(literals.join('\n'), { skipDown: true })
808    const deletes = SCRIPT_DELETE.test(code)
809    const risky = deletes || riskOf(findings) === 'high'
810    return { reason: 'change a migration file from a script', risky, note: deletes ? NOTE.shellDeletes : NOTE.scriptWrites, findings, part: shortPath(hit) }
811  }
812
813  const calls = (re: RegExp) => {
814    let looked = 0
815    for (const m of code.matchAll(re)) {
816      if (!insideAny(ranges, m.index ?? 0)) return true
817      if (++looked > 50) break
818    }
819    return false
820  }
821  const words = literals.join(' ')
822  if (calls(SCRIPT_SPAWNS)) {
823    for (const [re, what] of RUNNERS) {
824      const ran = re.exec(words)?.[0]
825      if (ran) return { reason: `run ${what} from a script`, ...riskOfWords(words), findings: [], part: ran }
826    }
827  }
828  if (calls(SCRIPT_QUERIES)) {
829    const findings = findDestructive(words, { keepComments: true })
830    const first = findings[0]
831    if (first) return { reason: 'run destructive SQL from a script', risky: riskOf(findings) === 'high', note: NOTE.sqlDirect, findings, part: first.snippet }
832  }
833  return undefined
834}
835
836function dedupe(findings: Finding[]): Finding[] {
837  return findings.filter((f, i) => findings.findIndex((g) => g.label === f.label) === i)
838}
839
840/** Why a shell command touches migrations or the database, or undefined when it doesn't. */
841export function classifyCommand(
842  command: string,
843  opts: { extra?: RegExp; extraPath?: RegExp; powershell?: boolean } = {},
844): CommandHit | undefined {
845  const powershell = opts.powershell === true
846  if (command.length > MAX_SCAN) return { reason: 'run a command too long to check', risky: true, note: NOTE.tooLong, findings: [], part: command.slice(-100) }
847  // A line continuation (\ in sh, ` in PowerShell) joins one command.
848  command = command.replace(powershell ? /`\r?\n/g : /\\\r?\n/g, ' ')
849  const heredocs = readHeredocs(command, opts.extraPath)
850  const steps = splitSteps(heredocs.text, false, powershell)
851  const live = steps.filter((s) => !onlyMentions(s, powershell))
852  const liveText = live.map((s) => s.text).join('\n')
853  const findings = findDestructive(liveText, { keepComments: true })
854
855  if (opts.extra?.test(command)) return { reason: 'run a command from your extraCommandPattern', ...riskOfExtra(command), findings, part: command }
856
857  for (const step of live) {
858    for (const [re, reason, note] of DATA_WIPES) {
859      if (re.test(step.text)) return { reason, risky: true, note, findings, part: step.text.trim() }
860    }
861    if (/\bpg_restore\b[^|;&\n]{0,300}\s(-d|--dbname)\b|\bmongorestore\b/i.test(step.text)) {
862      const clean = /\s(-c|--clean|--drop)\b/i.test(step.text)
863      return { reason: 'restore a dump into a database', risky: clean, note: clean ? NOTE.restoreClean : NOTE.restore, findings, part: step.text.trim() }
864    }
865  }
866
867  const client = live.find((s) => DB_CLIENT.test(s.text))
868  if (client) {
869    const part = client.text.trim()
870    if (findings.length > 0) return { reason: 'run destructive SQL directly', risky: riskOf(findings) === 'high', note: NOTE.sqlDirect, findings, part }
871    const at = client.text.search(DB_CLIENT)
872    if (SQL_FILE_INPUT.test(client.text.slice(at)) || SQL_PIPED_IN.test(client.text.slice(0, at))) {
873      return { reason: 'run a SQL file against a database', risky: true, note: NOTE.sqlFile, findings, part }
874    }
875  }
876
877  // Each step on its own, so `rm -rf /tmp/x && echo "see migrations/"` doesn't
878  // count. Pipes stay inside a step (`find migrations | xargs rm`), and a
879  // `cd` into a migrations folder carries over until the next `cd`.
880  let insideMigrations = false
881  let wentInside = false
882  for (const step of steps) {
883    if (CD_INTO_MIGRATIONS.test(step.text)) insideMigrations = wentInside = true
884    else if (CD.test(step.text)) insideMigrations = false
885    if (writesMigration(step.text, opts.extraPath)) return shellChange(step, heredocs.written)
886    if (onlyMentions(step, powershell)) continue
887    if (ONE_LINER.test(step.text) && !IN_PLACE.test(step.text)) {
888      const hit = scriptChange(oneLinerCode(step.text), opts.extraPath, insideMigrations)
889      if (hit) return hit
890      continue
891    }
892    // Quoted text is a search pattern or a message, unless the step hands it to a shell.
893    const verbs = RUNS_QUOTED.test(step.text) ? step.text : unquoted(step.text)
894    const patch = APPLIES_PATCH.test(verbs)
895    // sed -i and perl -pi: the script they run is not a path, so only the files they edit count.
896    const where = patch ? liveText : IN_PLACE.test(verbs) && !OTHER_FILE_VERB.test(verbs) ? withoutEditScript(step.text) : step.text
897    if ((patch || FILE_VERB.test(verbs)) && (insideMigrations || MIGRATION_DIR_WORD.test(where))) {
898      return shellChange(step, [])
899    }
900    if (COPY_VERB.test(verbs)) {
901      const to = destination(step.text)
902      if (MIGRATION_DIR_WORD.test(to) || (insideMigrations && !/^([\\/~]|[a-z]:)/i.test(to))) return shellChange(step, [])
903    }
904  }
905  for (const script of heredocs.scripts) {
906    const hit = scriptChange(script, opts.extraPath, wentInside)
907    if (hit) return hit
908  }
909
910  for (const step of live) {
911    if (ONE_LINER.test(step.text)) continue // judged as a script above
912    for (const part of splitSteps(step.text, true, powershell)) {
913      if (READ_ONLY.test(part.text) || (ONLY_MENTIONS.test(part.text) && !part.substitutes)) continue
914      // Quotes the shell removes don't hide a name: npm run 'db:migrate', mig""ration:run
915      const plain = part.text.replace(/["']/g, '')
916      for (const [re, what] of RUNNERS) {
917        if (re.test(part.text) || re.test(plain)) return { reason: `run ${what}`, ...riskOfWords(part.text), findings, part: part.text.trim() }
918      }
919    }
920  }
921  return undefined
922}
923
924function riskOfExtra(command: string): { risky: boolean; note: string } {
925  const { risky } = riskOfWords(command)
926  return { risky, note: NOTE.extra }
927}
928
929// ------------------------------------------------------------- MCP tools
930
931export type McpHit = CommandHit & { kind: 'file' | 'command'; key: string; target: string }
932
933const MCP_READS = /^(list|get|show|describe|read|search|find|fetch|view|check|explain)|[-_](status|list|history)$/i
934const MCP_RUNS_SQL = /sql|query|exec|statement|transaction|(^|[-_])run([-_]|$)/i
935const MCP_WRITES = /write|edit|create|update|move|rename|delete|remove|patch|put|append|replace|copy|upload|push|commit/i
936const PATH_KEY = /path|file|source|destination|target|^(from|to|src|dest|uri)$/i
937
938/** Every string in a tool's arguments with the key it sits under, a few levels deep. */
939function stringLeaves(value: unknown, key = '', depth = 0, out: { key: string; value: string }[] = []) {
940  if (out.length >= 200 || depth > 4) return out
941  if (typeof value === 'string') out.push({ key, value })
942  else if (Array.isArray(value)) for (const item of value) stringLeaves(item, key, depth + 1, out)
943  else if (value && typeof value === 'object') {
944    for (const [k, v] of Object.entries(value)) stringLeaves(v, k, depth + 1, out)
945  }
946  return out
947}
948
949/**
950 * An MCP tool that migrates (Supabase apply_migration, Prisma migrate-reset),
951 * runs destructive SQL (execute_sql, run_sql), or writes a migration file
952 * (a filesystem or GitHub server).
953 */
954export function classifyMcpCall(tool: string, args: Readonly<Record<string, unknown>>, extraPath?: RegExp): McpHit | undefined {
955  const [, server = '', ...rest] = tool.split('__')
956  const name = rest.join('__')
957  const label = `${name} (${server.replace(/^claude_ai_/i, '')})`
958  const leaves = stringLeaves(args)
959  const text = capped(leaves.map((l) => l.value).join('\n'))
960  const findings = findDestructive(text, { keepComments: true })
961  const key = `${tool}\n${text}`
962
963  if (/migrat/i.test(name) && !MCP_READS.test(name)) {
964    const words = riskOfWords(name)
965    const note = words.note === NOTE.applies ? NOTE.mcp : words.note
966    return { kind: 'command', key, target: label, reason: 'change the database with an MCP tool', risky: words.risky, note, findings, part: label }
967  }
968  if (MCP_WRITES.test(name)) {
969    const path = leaves.find((l) => PATH_KEY.test(l.key) && isMigrationPath(l.value, extraPath))
970    if (path) {
971      const content = leaves.filter((l) => !PATH_KEY.test(l.key)).map((l) => l.value).join('\n')
972      return {
973        kind: 'file',
974        key: approvalKey(path.value),
975        target: shortPath(path.value),
976        reason: `change a migration file with ${label}`,
977        risky: false,
978        note: '',
979        findings: isUndoFile(path.value) ? [] : findDestructive(content, { skipDown: true, hashComments: usesHashComments(path.value) }),
980        part: label,
981      }
982    }
983  }
984  if (findings.length > 0 && MCP_RUNS_SQL.test(name)) {
985    return { kind: 'command', key, target: label, reason: 'run destructive SQL with an MCP tool', risky: riskOf(findings) === 'high', note: NOTE.sqlDirect, findings, part: label }
986  }
987  return undefined
988}
989
990// ------------------------------------------------------------- helpers
991
992/** A user-supplied pattern from the plugin's settings; an invalid one is ignored. */
993export function safeRegex(source: unknown): RegExp | undefined {
994  if (typeof source !== 'string' || source.trim() === '') return undefined
995  try {
996    return new RegExp(source, 'i')
997  } catch {
998    return undefined
999  }
1000}
1001
1002/** The last three path segments, so the question stays readable. */
1003export function shortPath(path: string): string {
1004  const parts = path.split(/[\\/]/).filter(Boolean)
1005  return parts.length > 3 ? `.../${parts.slice(-3).join('/')}` : path
1006}
1007
1008/** Windows paths are case-insensitive, so "don't ask again" shouldn't care about case there. */
1009export function approvalKey(path: string): string {
1010  return /^[a-z]:[\\/]|\\/i.test(path) ? path.toLowerCase() : path
1011}
1012
hooks/history.ts 64 lines
1// What was asked and what you answered, kept in Claude Code's own store for
2// this mod (not a file in your project) and shown by /migration-guard.
3
4import type { Level } from './detect'
5
6export type Entry = {
7  /** When, in ms. */
8  at: number
9  /** The folder Claude was working in. */
10  project: string
11  /** "write a migration file", "run prisma", ... */
12  what: string
13  target: string
14  risk: Level
15  labels: string[]
16  /** What the person picked or typed, or why nobody was asked. */
17  answer: string
18}
19
20export const HISTORY_KEY = 'history'
21export const HISTORY_LIMIT = 300
22
23/** Passwords and tokens in commands never reach the store. */
24export function redact(text: string): string {
25  const out = text
26    .replace(/\b([a-z][\w+.-]*:\/\/[^\s:/@]+:)[^\s@/]+@/gi, '$1***@') // postgres://user:REDACTED@host
27    .replace(/\b([A-Z0-9_]*(?:PASSWORD|PASSWD|PWD|TOKEN|SECRET|API_?KEY)[A-Z0-9_]*)=("[^"]*"|'[^']*'|[^\s'"]+)/gi, '$1=***')
28    .replace(/(--password\s)("[^"]*"|'[^']*'|[^\s'"]+)/gi, '$1***')
29  // mysql -psecret: only for the MySQL tools, where -p glued to a value is the password
30  return /\b(mysql|mariadb|mysqladmin|mysqldump)\b/i.test(out) ? out.replace(/(\s-p)(?=[^\s-])("[^"]*"|'[^']*'|\S+)/g, '$1***') : out
31}
32
33/** Adds an entry and drops the oldest past the limit. */
34export function append(list: readonly Entry[], entry: Entry): Entry[] {
35  const next = [...list, { ...entry, target: redact(entry.target).slice(0, 200) }]
36  return next.length > HISTORY_LIMIT ? next.slice(next.length - HISTORY_LIMIT) : next
37}
38
39/** Reads what the store holds, ignoring anything that isn't a list of entries. */
40export function asEntries(value: unknown): Entry[] {
41  if (!Array.isArray(value)) return []
42  return value.filter((v): v is Entry => typeof v === 'object' && v !== null && typeof (v as { at?: unknown }).at === 'number')
43}
44
45const pad = (n: number) => String(n).padStart(2, '0')
46
47function when(ms: number): string {
48  const d = new Date(ms)
49  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
50}
51
52/** The newest `count` entries, newest first, one line each, for /migration-guard. */
53export function render(list: readonly Entry[], count: number): string {
54  if (list.length === 0) return 'Nothing asked yet.'
55  const shown = list.slice(-count).reverse()
56  const lines = shown.map((e) => {
57    const found = e.labels.length > 0 ? ` (${e.labels.join(', ')})` : ''
58    return `${when(e.at)}  ${e.answer}  [${e.risk}]  ${e.what}: ${e.target}${found}  in ${e.project}`
59  })
60  const blocked = list.filter((e) => /^(No|blocked)/.test(e.answer) || e.answer.startsWith('"')).length
61  const head = `Last ${shown.length} of ${list.length} (${blocked} blocked). /migration-guard all shows ${HISTORY_LIMIT}, /migration-guard clear empties it.`
62  return [head, ...lines].join('\n')
63}
64
hooks/question.ts 84 lines
1// The words the person sees. Kept apart so they're easy to read, test and change.
2
3import { bySeverity, impactOf, riskOf, type Finding, type Level } from './detect'
4
5export const YES = 'Yes'
6export const YES_FILE = 'Yes for this file'
7export const YES_COMMAND = 'Yes for this command'
8export const YES_ALL = 'Yes to all files, 15 min'
9export const NO = 'No'
10
11/** How long "Yes to all files" lasts. High-risk changes still ask inside it. */
12export const ALL_WINDOW_MS = 15 * 60_000
13
14const HEADER: Readonly<Record<Level, string>> = { high: 'High risk', medium: 'Medium risk', low: 'Low risk' }
15
16export type Ask = {
17  /** A file Claude writes or edits, or a command (shell or MCP). */
18  kind: 'file' | 'command'
19  /** What "Yes for this file/command" remembers: the file or the exact command. */
20  key: string
21  /** "write a migration file", "run prisma", ... */
22  what: string
23  /** The short file path, the command, or the MCP tool, as shown. */
24  target: string
25  findings: Finding[]
26  /** Why it matters when nothing destructive was spotted. */
27  note?: string
28  /** The command's own risk, used when there are no findings. */
29  baseRisk?: Level
30  /** One more line worth knowing, shown last. */
31  hint?: string
32}
33
34export function levelOf(ask: Ask): Level {
35  return riskOf(ask.findings, ask.baseRisk)
36}
37
38export type Option = { label: string; description: string }
39
40const OPTION: Readonly<Record<string, string>> = {
41  [NO]: 'Block it. Claude is told to stop and not work around it.',
42  [YES]: 'Allow this once.',
43  [YES_FILE]: "Don't ask again for this file, unless something more destructive shows up.",
44  [YES_COMMAND]: "Don't ask again for this exact command in this session.",
45  [YES_ALL]: 'Ordinary migration file changes go through for 15 minutes. High risk still asks.',
46}
47
48/** The one line under an option in the dialog. */
49export function describeOption(label: string): string {
50  return OPTION[label] ?? ''
51}
52
53/**
54 * The buttons, No first: anything that picks the first one by default picks
55 * the safe one. "Yes to all files" only shows for files that aren't high
56 * risk, since it never covers high-risk changes anyway.
57 */
58export function optionsFor(ask: Ask): Option[] {
59  const labels =
60    ask.kind === 'command' ? [NO, YES, YES_COMMAND] : levelOf(ask) === 'high' ? [NO, YES, YES_FILE] : [NO, YES, YES_FILE, YES_ALL]
61  return labels.map((label) => ({ label, description: OPTION[label] ?? '' }))
62}
63
64/** Risk in the header chip; then what was found and what it does, worst first. */
65export function questionFor(ask: Ask): { header: string; question: string } {
66  const findings = bySeverity(ask.findings)
67  const lines = findings.slice(0, 2).map((f) => `${f.label}: ${impactOf(f.label).what}`)
68  if (findings.length > 2) lines.push(`+${findings.length - 2} more`)
69  // The line it came from, unless the command shown already says it.
70  const snippet = findings[0]?.snippet.replace(/[;\s]+$/, '') ?? ''
71  const evidence = snippet && !ask.target.includes(snippet) ? ` Found: ${snippet}.` : ''
72  const detail = lines.length > 0 ? `${lines.join('; ')}.${evidence}` : `${capitalize(ask.note ?? 'nothing destructive spotted')}.`
73  const hint = ask.hint ? ` ${ask.hint}` : ''
74  const stop = /[.!?;]$/.test(ask.target) ? '' : '.'
75  return {
76    header: HEADER[levelOf(ask)],
77    question: `Claude wants to ${ask.what}: ${ask.target}${stop} ${detail}${hint} Allow it?`,
78  }
79}
80
81function capitalize(text: string): string {
82  return text.charAt(0).toUpperCase() + text.slice(1)
83}
84