SLOPSHOPPER

sql-concat-watch

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

newguardcommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sql-concat-watch
› 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 › /sql-concat-watch ⎿ sql-concat-watch: on · mode note · no file is open ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

sql-concat-watch

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.

What it does

  1. The mod hooks the Edit and Write tools. After a successful call on a source file (.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.
  2. A line counts when it holds SQL and joins a value into it:
SQLJoins
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 casePython 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.

  1. The model reads this note after the Edit's result:

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.

  1. At the same moment one line reaches the transcript, so you see what the model was told. The line holds the places alone, without the instruction:

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.

  1. While the sidebar is open, those places go there instead, one red line each, as an entry in its stream, and the transcript stays clean; the places past the eighth are one faint count. The entry stays until newer ones push it off the pane. With the sidebar closed, or without that mod installed, the transcript line is written as above.
  1. A finding stays open until the file no longer holds those lines. The mod reads each open file again after every later Edit or Write, at the end of each main-loop turn, and before a guarded git command in 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.

  1. A finding the model did not close is measured again at the end of each main-loop turn, and what is left reaches the model as one note with its next prompt:

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.

  1. In 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.

Command

/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

Install

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.

After installing

  1. Restart Claude Code.

What it can reach

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.

  1. Reads: the text of each Edit and Write call; the Bash command text; the edited file after an Edit that joins SQL, for the line numbers, and each open file again while a finding stands
  2. Runs: git rev-parse --show-toplevel once at the session's start, to show paths against the repository root; git rev-parse --show-toplevel and git diff --cached --name-only -z, at a commit in deny mode, to read which files the commit holds
  3. Sends: a note to the model after an edit that joins SQL, one more with the next prompt while a finding stands, and one line to the transcript; nothing leaves the machine
  4. Persists: in $.store, the on/off setting and the mode
  5. Hostile input: the edited text is only matched by regular expressions and printed as file:line, never run

Limits

  • The check is line by line with regular expressions, not a parser. A query built in a variable over several statements (q = "SELECT …"; q += id) is not seen.
  • A value joined into a table or column name, where a parameter cannot stand, is reported too. The note asks for a reason there, it stops nothing.
  • A query builder call (knex.raw, DB::raw, whereRaw) with a joined string is seen only when the string itself holds SQL keywords.
  • An edit through Bash is not checked.
  • A finding closes when the reported lines are gone from the file. A line moved to another file keeps it open.
  • The deny mode has no bypass. When a finding cannot be fixed, you turn the gate off with /sql-concat-watch mode note.
  • The gate reads the command text. A commit through a script or an alias that hides git commit is not stopped.
  • A 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.
  • The index is read before the command runs. A commit whose files change between the read and the run is measured against what the index held at the read.

Development

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

Source 2 files
hooks/register.ts 257 lines
1import 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}
257
hooks/sql.ts 202 lines
1/** 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