After an edit that builds SQL by joining or interpolating strings, names each line, so the model passes the values as query parameters.

The model writes ` db.query(SELECT * FROM users WHERE id = ${id}) , the query works in the test, and an SQL injection hole goes into the code. This mod tells the model when an edit builds SQL by joining strings instead of passing query parameters. The note comes with the Edit's result and names each line, so the model rewrites the query in the same turn. By default nothing is stopped; in deny` mode a commit, a push and a merge stop while a file still joins SQL.
.ts, .tsx, .js, .jsx, .mjs, .cjs, .py, .php, .go, .rb, .java, .kt, .cs, .rs), it reads the lines the edit added: those of new_string that old_string does not have, or every line of a Write.| SQL | Joins |
|---|---|
SELECT … FROM, INSERT INTO, UPDATE … SET, DELETE FROM, WHERE, VALUES, ORDER BY, GROUP BY, JOIN in upper case | "…" + x and x + "…" (JS, Java, Go, C#, Kotlin) |
select * or select a, b from, insert into t (, delete from t where, update t set a = in any case | Python f-strings, % and .format( |
PHP "… $var" and "…" . $var, Kotlin "… $var" | |
C# $"… {x}", Ruby "… #{x}" | |
fmt.Sprintf(, String.format(, format!( | |
a template literal with ${…}, also over several lines |
A template literal whose tag passes the values as parameters is left alone: ` sql… , Prisma.sql… , prisma.$queryRaw… , $executeRaw… . $queryRawUnsafe is not such a tag. Comment lines and English prose ("select a file from the list: " + name`) do not count.
sql-concat-watch: this edit builds SQL from strings: src/db.ts:14 · src/db.ts:22. Pass values as query parameters (?, $1, :name) instead of joining them into the SQL text.
The line number comes from the file after the edit; a Write is numbered from its own content. At most 8 places are named, the rest counted. When the file cannot be read, the path stands without a line and the error is logged once. The path is written against the git repository the session started in when the file is inside it, so a session opened in web/ names a file of api/ as api/db.ts. Outside a git repository it is written against the directory the session started in. That root is read once at the session's start, because a Bash cd moves the session's own directory.
sql-concat-watch: SQL built from strings: src/db.ts:14 · src/db.ts:22
The note and the line are separate channels: the model never reads the line, and you never read the note.
deny mode; a file whose lines are all gone closes. A line counts as gone when it no longer builds SQL from strings: taken out, rewritten with parameters, or commented out. A file that keeps some of its lines stays open with only those, at the line numbers they stand on now; the record is replaced by what the file holds, never joined with what it held before. A file that is no longer there closes too, because it holds no line any more; a file that is there and cannot be read keeps its finding, because an unread file proves nothing. A green entry says so:sql-concat-watch: the SQL built from strings is gone from src/db.ts: src/db.ts:14 · src/db.ts:22
With the sidebar closed the same text is one transcript line. The model reads nothing of this: it rewrote the query itself.
sql-concat-watch: 2 place(s) still build SQL from strings: src/db.ts:14 · src/db.ts:22. Pass the values as query parameters (?, $1, :name), or take the lines out.
One note per turn, not one per prompt. Without this the finding would be said once, at the edit, and then stand in the pane while the model forgot it. You read nothing new: the pane already carries the same finding.
deny mode the mod also stops git commit, git push and git merge while a file still builds SQL from strings; a command with --dry-run, --help or -h is not stopped. Before it stops one it reads each open file again, so a file the model fixed opens the gate itself. A git commit answers for its own files alone: the mod reads the index (git diff --cached --name-only -z) and lets the commit run when it holds none of the open files, with one line to you naming how many still stand. A push and a merge hold no index to read, so every finding stands there. There is no bypass; only you turn the gate off, with /sql-concat-watch mode note. note mode is the default and stops nothing.In the live check the model put ` db.query(SELECT * FROM users WHERE id = ${id}) into a file with one Edit, read the note naming src/users.ts:3`, and named the parameterized form in its answer.
/sql-concat-watch on or off, the mode, and the files still joining SQL /sql-concat-watch on | off on by default /sql-concat-watch mode note note only; the default /sql-concat-watch mode deny a commit, a push and a merge also stop while a file joins SQL
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install sql-concat-watch@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
Validated with claude plugin validate on Claude Code 2.1.283:
❯ ./register.ts hooks: session.start, command.run{command=sql-concat-watch}, turn.complete, prompt.submit, tool.call{tool=Bash}, tool.call{tool=Edit}, tool.call{tool=Write} ❯ ./register.ts calls: $.command.register, $.fs.exists (via isGone), $.fs.read (via fileText), $.process.run (via shownRootOf, stagedPaths), $.session.cwd, $.sidebar.clear (via dropEntry), $.sidebar.set (via toPerson), $.store.get (via readSettings), $.store.set (via runCommand, setMode), $.ui.log (via fileText, gate, toPerson)
Reach L2, it runs git to read the index.
q = "SELECT …"; q += id) is not seen.knex.raw, DB::raw, whereRaw) with a joined string is seen only when the string itself holds SQL keywords.deny mode has no bypass. When a finding cannot be fixed, you turn the gate off with /sql-concat-watch mode note.git commit is not stopped.git commit -a, a -am and a commit with a pathspec after -- are not narrowed to the index, because they commit files the index does not hold yet. Every open finding stands for those.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.ts 257 lines1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { denyText, doneLines, doneLog, isCommit, isGuarded, isNarrowable, isSource, logText, modeOf, noteText, openNote, placesOf, sectionKey, shownPath, sidebarLines, sqlLines, stillBuilt, type Line, type Mode } from './sql.ts'
3
4const ENABLED_KEY = 'enabled'
5const MODE_KEY = 'mode'
6
7const USAGE = 'expects nothing (the status), on, off or mode note | deny'
8
9/** One file's open finding: the file on disk, the lines that build SQL, and the places they were reported at. */
10type Finding = { path: string; lines: string[]; places: string[] }
11
12/**
13 * The on/off setting and the mode as the store held them at the last read, whether a read error was
14 * logged, the open findings by shown path, whether the model is owed a note for them, and the root read
15 * once at the session's start (`shownRootOf`). A path is shown against that root, not against
16 * `$.session.cwd()`, because a Bash `cd` moves the session's directory and would then leave every path
17 * outside it written in full.
18 */
19type State = { enabled: boolean; mode: Mode; reported: boolean; open: Map<string, Finding>; owed: boolean; root?: string }
20
21/**
22 * Reads the on/off setting and the mode from the store, which every window shares, so a change made in
23 * another window applies here at the next hook that acts on it.
24 */
25async function readSettings($: EngineInterface, state: State): Promise<void> {
26 state.enabled = (await $.store.get(ENABLED_KEY)) !== false
27 state.mode = (await $.store.get(MODE_KEY)) === 'deny' ? 'deny' : 'note'
28}
29
30/**
31 * The git repository the session started in, so a file in a sibling directory of a session opened in a
32 * subdirectory still reads short; the session's own directory where git does not answer.
33 */
34async function shownRootOf($: EngineInterface, cwd: string): Promise<string> {
35 try {
36 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
37 const root = top.stdout.trim()
38 return top.exitCode === 0 && root !== '' ? root : cwd
39 } catch {
40 // No git here, or the command did not run: paths are shown against the session's directory.
41 return cwd
42 }
43}
44
45/** Whether the file is no longer there, so a finding of it closes instead of standing for good. */
46async function isGone($: EngineInterface, path: string): Promise<boolean> {
47 try {
48 return !(await $.fs.exists(path))
49 } catch {
50 // The path was not measured: the finding is left as it stands.
51 return false
52 }
53}
54
55/** The file's text after the edit, or undefined when it cannot be read; the first failure is logged. */
56async function fileText($: EngineInterface, state: State, path: string): Promise<string | undefined> {
57 try {
58 return await $.fs.read(path)
59 } catch (err) {
60 if (!state.reported) $.ui.log(`line numbers were left out, the edited file was not read: ${err instanceof Error ? err.message : String(err)}`)
61 state.reported = true
62 return undefined
63 }
64}
65
66/**
67 * The finding the person reads: an entry in the shared sidebar's stream while it is open, else the
68 * transcript line, as before. The model's note is another channel and does not change here.
69 */
70async function toPerson($: EngineInterface, key: string, title: string, lines: Line[], line: string): Promise<void> {
71 try {
72 const taken = await $.sidebar.set({ consumer: 'sql-concat-watch', key: sectionKey(key), title, lines, until: 'stream' })
73 if (taken) return
74 } catch {
75 // The sidebar mod is not installed.
76 }
77 $.ui.log(line)
78}
79
80/** Drops the sidebar entries of one finding, so a file that no longer builds SQL leaves no warning behind. */
81async function dropEntry($: EngineInterface, key: string): Promise<void> {
82 try {
83 await $.sidebar.clear({ consumer: 'sql-concat-watch', key: sectionKey(key) })
84 } catch {
85 // The sidebar mod is not installed.
86 }
87}
88
89/**
90 * Reads each open file again. A finding whose lines are all gone closes; one that keeps some of them is
91 * replaced by what the file holds now, never joined with what it held before. An unreadable file stays
92 * as it stands. `skip` is the file this edit just measured, so the mod does not read it a second time.
93 */
94async function closeResolved($: EngineInterface, state: State, skip?: string): Promise<void> {
95 for (const [shown, found] of [...state.open]) {
96 if (shown === skip) continue
97 // A file that is gone holds no line any more; one that is there and unreadable proves nothing.
98 const text = (await isGone($, found.path)) ? '' : await fileText($, state, found.path)
99 if (text === undefined) continue
100 const left = stillBuilt(text, found.lines)
101 if (left.length > 0) {
102 state.open.set(shown, { path: found.path, lines: left, places: placesOf(shown, text, left) })
103 continue
104 }
105 state.open.delete(shown)
106 await dropEntry($, shown)
107 await toPerson($, shown, 'SQL parameters used', doneLines(shown, found.places), doneLog(shown, found.places))
108 }
109}
110
111/** The note of one edit and the file it named, and the finding it opens; undefined when the edit builds no SQL. */
112async function noteFor($: EngineInterface, state: State, path: string, before: string, after: string): Promise<{ note: string; shown: string } | undefined> {
113 const lines = sqlLines(before, after)
114 if (lines.length === 0) return undefined
115 const shown = shownPath(path, state.root ?? (await $.session.cwd()))
116 const text = before === '' ? after : await fileText($, state, path)
117 const held = state.open.get(shown)?.lines ?? []
118 // The lines reported before are measured in the file as it is now, then this edit's lines join them.
119 const kept = text === undefined ? held : stillBuilt(text, held)
120 const all = [...new Set([...kept, ...lines])]
121 state.open.set(shown, { path, lines: all, places: placesOf(shown, text, all) })
122 const places = placesOf(shown, text, lines)
123 // The note goes to the model, the line to the person: neither reads the other's channel.
124 await toPerson($, shown, 'SQL built from strings', sidebarLines(places), logText(places))
125 return { note: noteText(places), shown }
126}
127
128/** Adds the note to an edit whose new lines build SQL from strings, and closes what a later edit fixed. */
129async function afterEdit($: EngineInterface, state: State, path: string, before: string, after: string, r: ToolCallResult): Promise<ToolCallResult> {
130 if (r.deny !== undefined || r.isError === true) return r
131 await readSettings($, state)
132 if (!state.enabled) return r
133 const found = isSource(path) ? await noteFor($, state, path, before, after) : undefined
134 await closeResolved($, state, found?.shown)
135 return found === undefined ? r : { ...r, context: [...(r.context ?? []), found.note] }
136}
137
138/**
139 * The files this commit holds, by absolute path, or undefined when git did not answer. Read before the
140 * command runs, so it is the index as the commit will take it.
141 */
142async function stagedPaths($: EngineInterface, state: State): Promise<Set<string> | undefined> {
143 try {
144 const cwd = state.root ?? (await $.session.cwd())
145 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
146 const staged = await $.process.run(['git', 'diff', '--cached', '--name-only', '-z'], { cwd })
147 if (top.exitCode !== 0 || staged.exitCode !== 0) return undefined
148 const base = top.stdout.trim()
149 return new Set(staged.stdout.split('\0').filter(Boolean).map(p => `${base}/${p}`))
150 } catch {
151 // No git here, or the command did not run: the findings are not narrowed.
152 return undefined
153 }
154}
155
156/**
157 * The findings this command answers for. A `git commit` answers for its own files alone, so a finding of
158 * a file the commit does not hold lets it run. A `push` or a `merge` holds no index to read, so every
159 * finding stands there.
160 */
161async function scopeOf($: EngineInterface, state: State, command: string): Promise<Finding[]> {
162 const all = [...state.open.values()]
163 if (!isCommit(command) || !isNarrowable(command)) return all
164 const staged = await stagedPaths($, state)
165 return staged === undefined ? all : all.filter(f => staged.has(f.path))
166}
167
168/**
169 * The gate of the `deny` mode: it reads each open file again, so a file the model fixed without a new
170 * finding opens the gate too. A file this command holds that still builds SQL from strings stops it, and
171 * there is no bypass.
172 */
173async function gate($: EngineInterface, state: State, command: string): Promise<string | undefined> {
174 if (state.open.size === 0 || !isGuarded(command)) return undefined
175 await readSettings($, state)
176 if (!state.enabled || state.mode !== 'deny') return undefined
177 await closeResolved($, state)
178 if (state.open.size === 0) return undefined
179 const scoped = await scopeOf($, state, command)
180 if (scoped.length === 0) {
181 $.ui.log(`${state.open.size} file(s) still build SQL from strings, and this command holds none of them`)
182 return undefined
183 }
184 return denyText(scoped.flatMap(f => f.places))
185}
186
187async function setMode($: EngineInterface, state: State, word: string): Promise<string> {
188 const mode = modeOf(word)
189 if (mode === undefined) return 'mode expects note or deny'
190 await $.store.set(MODE_KEY, mode)
191 state.mode = mode
192 return mode === 'deny' ? 'mode deny: git commit, push and merge stop while a file builds SQL from strings' : 'mode note: the places are only reported'
193}
194
195function statusText(state: State): string {
196 const open = state.open.size === 0 ? 'no file is open' : `${state.open.size} file(s) still build SQL from strings`
197 return `${state.enabled ? 'on' : 'off'} · mode ${state.mode} · ${open}`
198}
199
200async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
201 const word = args.trim()
202 if (word === 'on' || word === 'off') {
203 await $.store.set(ENABLED_KEY, word === 'on')
204 state.enabled = word === 'on'
205 return word === 'on' ? 'on: each edit is checked for SQL built from strings' : 'off: edits are not checked'
206 }
207 if (word.startsWith('mode')) return setMode($, state, word.slice(4).trim())
208 if (word !== '') return USAGE
209 await readSettings($, state)
210 return statusText(state)
211}
212
213export const register: Register = on => {
214 const state: State = { enabled: true, mode: 'note', reported: false, open: new Map(), owed: false }
215
216 on('session.start', async ($, e, next) => {
217 const r = await next(e)
218 await $.command.register({ name: 'sql-concat-watch', description: 'SQL an edit builds from strings: status, on, off, mode (sql-concat-watch)', argumentHint: '[on | off | mode note | deny]' })
219 await readSettings($, state)
220 state.root = await shownRootOf($, await $.session.cwd())
221 return r
222 })
223
224 // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
225 on('command.run', { command: 'sql-concat-watch' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
226
227 /*
228 * The turn's end reads every open file again and owes the model a note for what is left, because a
229 * finding it did not close would otherwise stand in the pane and reach it never again.
230 */
231 on('turn.complete', async ($, e, next) => {
232 const r = await next(e)
233 if (e.agentId !== undefined) return r
234 await readSettings($, state)
235 if (!state.enabled) return r
236 await closeResolved($, state)
237 state.owed = state.open.size > 0
238 return r
239 })
240
241 // The note goes to the model alone; the person reads the pane, which carries the same finding.
242 on('prompt.submit', async (_, e, next) => {
243 if (!state.owed || state.open.size === 0) return next(e)
244 state.owed = false
245 const note = openNote([...state.open.values()].flatMap(f => f.places))
246 return next({ ...e, context: [...(e.context ?? []), note] })
247 })
248
249 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
250 const stop = await gate($, state, e.command)
251 return stop === undefined ? next(e) : { deny: stop }
252 })
253
254 on('tool.call', { tool: 'Edit' }, async ($, e, next) => afterEdit($, state, e.file_path, e.old_string, e.new_string, await next(e)))
255 on('tool.call', { tool: 'Write' }, async ($, e, next) => afterEdit($, state, e.file_path, '', e.content, await next(e)))
256}
257hooks/sql.ts 202 lines1/** Lines of source code that build SQL by joining or interpolating strings. */
2
3/** Files whose lines are read. */
4const SOURCE = /\.(ts|tsx|js|jsx|mjs|cjs|py|php|go|rb|java|kt|cs|rs)$/
5
6/** SQL in upper case. */
7const SQL_UPPER = /\bSELECT\b.*\bFROM\b|\bINSERT\s+INTO\b|\bUPDATE\s+\S+\s+SET\b|\bDELETE\s+FROM\b|\b(WHERE|VALUES|ORDER BY|GROUP BY|JOIN)\b/
8
9/** SQL in any case, only in shapes English prose does not take ("select a file from the list" does not match). */
10const SQL_ANY_CASE = /\bselect\s+(\*|[\w.]+(\s*,\s*[\w.]+)+)\s+from\b|\binsert\s+into\s+[\w."`]+\s*(\(|values\b|select\b)|\bdelete\s+from\s+[\w."`]+\s+where\b|\bupdate\s+[\w."`]+\s+set\s+\w+\s*=/i
11
12/** Ways a line joins a value into a string, across the languages read. */
13const JOINS = [
14 /["']\s*\+\s*[\w$(]/, // "... " + value (JS, Java, Go, C#, Kotlin)
15 /[\w)\]]\s*\+\s*["']/, // value + " ..."
16 /\bf"[^"]*\{|\bf'[^']*\{/, // Python f-string
17 /["']\s*%\s*[\w(]/, // Python % formatting
18 /["']\s*\.format\(/, // Python str.format
19 /["']\s*\.\s*\$\w|\$\w+\s*\.\s*["']/, // PHP "..." . $var
20 /\$"[^"]*\{/, // C# $"... {x}"
21 /\b(fmt\.Sprintf|String\.format|format!)\(/, // Go, Java, Rust formatting
22]
23
24/** Interpolation inside a double-quoted string body: PHP and Kotlin `$var`, Ruby `#{x}`. */
25const INTERPOLATES = /\$[A-Za-z_{]|#\{/
26
27/**
28 * Whether a double-quoted string on the line interpolates a value. Only the text between a string's own
29 * quotes is read, so `fetchRow("... = ?", [$id])` does not read its parameter array as part of the SQL.
30 */
31function interpolates(line: string): boolean {
32 return [...line.matchAll(/"((?:[^"\\]|\\.)*)"/g)].some(m => INTERPOLATES.test(m[1] ?? ''))
33}
34
35/** The last name of a template tag that makes the values parameters: sql`...`, Prisma.sql`...`, prisma.$queryRaw`...`. */
36const SAFE_TAG = /^(sql|SQL|\$queryRaw|\$executeRaw)$/
37
38const isSafeTag = (tag: string): boolean => SAFE_TAG.test(tag.split('.').at(-1) ?? '')
39
40const COMMENT = /^\s*(\/\/|#|\*|\/\*|--)/
41
42export function isSource(path: string): boolean {
43 return SOURCE.test(path)
44}
45
46function hasSql(text: string): boolean {
47 return SQL_ANY_CASE.test(text) || SQL_UPPER.test(text)
48}
49
50/** A single line that holds SQL and joins a value into it; a line inside a template literal is left to `templateLines`. */
51function joinsSql(line: string): boolean {
52 if (COMMENT.test(line) || !hasSql(line)) return false
53 const plain = line.replace(/`[^`]*`/g, '``')
54 return JOINS.some(re => re.test(plain)) || interpolates(plain)
55}
56
57/** The lines with a `${` of each untagged or unsafe-tagged template literal that holds SQL. */
58function templateLines(text: string): string[] {
59 const out: string[] = []
60 for (const m of text.matchAll(/([\w.$]*)`([^`]*)`/g)) {
61 const body = m[2] ?? ''
62 if (isSafeTag(m[1] ?? '') ||!body.includes('${') || !hasSql(body)) continue
63 const first = body.split('\n').find(l => l.includes('${')) ?? ''
64 const line = text.split('\n').find(l => l.includes(first.trim())) ?? first
65 // A template inside a commented-out line builds nothing.
66 if (!COMMENT.test(line)) out.push(line)
67 }
68 return out
69}
70
71/** The trimmed lines of `after` that build SQL from strings, without those `before` already had. */
72export function sqlLines(before: string, after: string): string[] {
73 const old = new Set(before.split('\n').map(l => l.trim()))
74 const found = [...after.split('\n').filter(joinsSql), ...templateLines(after)].map(l => l.trim())
75 return [...new Set(found)].filter(l => l !== '' && !old.has(l))
76}
77
78/**
79 * The reported lines a file's text still builds SQL from strings with, measured by the same reading as a
80 * new edit, so a line taken out, rewritten with parameters or commented out no longer counts.
81 */
82export function stillBuilt(text: string, lines: string[]): string[] {
83 const built = sqlLines('', text)
84 return lines.filter(l => built.some(b => b.includes(l)))
85}
86
87/** The place of each line in the file's text now, or the bare file when the text was not read. */
88export function placesOf(shown: string, text: string | undefined, lines: string[]): string[] {
89 return lines.map(l => {
90 const n = text === undefined ? undefined : lineOf(text, l)
91 return n === undefined ? shown : `${shown}:${n}`
92 })
93}
94
95/** The 1-based number of the first line of `text` that holds `line`; an Edit's text can be part of a line. */
96export function lineOf(text: string, line: string): number | undefined {
97 const i = text.split('\n').findIndex(l => l.includes(line))
98 return i < 0 ? undefined : i + 1
99}
100
101/** `path` shown relative to the session directory when it is inside it. */
102export function shownPath(path: string, cwd: string): string {
103 const base = `${cwd.replace(/\/+$/, '')}/`
104 return path.startsWith(base) ? path.slice(base.length) : path
105}
106
107/** At most this many places are named, the rest counted. */
108const MAX_NAMED = 8
109
110function namedPlaces(places: string[]): string {
111 const named = places.slice(0, MAX_NAMED)
112 if (places.length > MAX_NAMED) named.push(`${places.length - MAX_NAMED} more`)
113 return named.join(' · ')
114}
115
116export function noteText(places: string[]): string {
117 return `sql-concat-watch: this edit builds SQL from strings: ${namedPlaces(places)}. Pass values as query parameters (?, $1, :name) instead of joining them into the SQL text.`
118}
119
120/**
121 * The note the model reads at the next prompt while a finding stands, so a finding it did not close
122 * reaches it again instead of standing in the pane alone. The person reads the pane and needs no line.
123 */
124export function openNote(places: string[]): string {
125 return `sql-concat-watch: ${places.length} place(s) still build SQL from strings: ${namedPlaces(places)}. Pass the values as query parameters (?, $1, :name), or take the lines out.`
126}
127
128/** The transcript line: the places alone, without the instruction the model reads. The engine adds the mod name. */
129export function logText(places: string[]): string {
130 return `SQL built from strings: ${namedPlaces(places)}`
131}
132
133/** A sidebar line of a finding or its closing; the count of the places left unnamed is faint. */
134export type Line = { text: string; kind: 'error' | 'ok' | 'dim' }
135
136/**
137 * One line per named place in the finding's colour, and the places past `MAX_NAMED` as one faint count,
138 * so the count does not read as one more place.
139 */
140function placeLines(places: string[], kind: 'error' | 'ok'): Line[] {
141 const named: Line[] = places.slice(0, MAX_NAMED).map(text => ({ text, kind }))
142 return places.length > MAX_NAMED ? [...named, { text: `${places.length - MAX_NAMED} more`, kind: 'dim' }] : named
143}
144
145/** One sidebar line per place, so the section reads as a list. */
146export function sidebarLines(places: string[]): Line[] {
147 return placeLines(places, 'error')
148}
149
150/** The transcript line of a finding a later edit closed. */
151export function doneLog(file: string, places: string[]): string {
152 return `the SQL built from strings is gone from ${file}: ${namedPlaces(places)}`
153}
154
155/** The sidebar lines of a closed finding: the file, then the places the strings left. */
156export function doneLines(file: string, places: string[]): Line[] {
157 return [{ text: file, kind: 'ok' }, ...placeLines(places, 'ok')]
158}
159
160/** The global flags git takes before the subcommand, so `git -c user.name=x commit` is still a commit. */
161const GIT_FLAG = String.raw`(?:\s+-[cC]\s+\S+|\s+--(?:git-dir|work-tree|namespace)=\S+|\s+--(?:no-pager|no-replace-objects|bare|literal-pathspecs|paginate))`
162
163/** A `git commit`, `git push` or `git merge` the gate stops while a finding is open. */
164const GUARDED = new RegExp(String.raw`(^|[\s;&|(])git(?:${GIT_FLAG})*\s+(commit|push|merge)\b`)
165const ASKING = /\s(--dry-run|--help|-h)(\s|$)/
166
167export function isGuarded(command: string): boolean {
168 return GUARDED.test(command) && !ASKING.test(command)
169}
170
171/** Whether the command is a `git commit`, the one guarded command whose own files can be measured. */
172export function isCommit(command: string): boolean {
173 return GUARDED.exec(command)?.[2] === 'commit'
174}
175
176/**
177 * Whether the index alone says what this commit holds. A `-a` or `-am` commit stages the tracked files
178 * as it runs, and a pathspec after `--` commits paths the index does not hold, so neither is narrowed.
179 */
180export function isNarrowable(command: string): boolean {
181 const words = command.split(/\s+/)
182 return !words.includes('--') && !words.some(w => w === '--all' || /^-[A-Za-z]*a/.test(w))
183}
184
185/** The mode of the mod: a note only, or a note and a gate on git commit, push and merge. */
186export type Mode = 'note' | 'deny'
187
188/** The mode a `/sql-concat-watch mode <word>` argument names, or undefined when it is not one. */
189export function modeOf(arg: string): Mode | undefined {
190 return arg === 'note' || arg === 'deny' ? arg : undefined
191}
192
193/** The deny text both the model and the person read: where the SQL is built, and the one way out. */
194export function denyText(places: readonly string[]): string {
195 return `stopped: ${places.length} place(s) build SQL from strings: ${namedPlaces([...places])}. Pass the values as query parameters (?, $1, :name), then run the command again; there is no way around this gate.`
196}
197
198/** A sidebar section key: the subject cut to what the sidebar takes, so one file keeps one section. */
199export function sectionKey(text: string): string {
200 return text.replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64) || 'note'
201}
202