SLOPSHOPPER

i18n-watch

After an edit that calls translation keys, names the keys the JSON, Laravel PHP, YAML or gettext locale files lack, and the languages that lack them.

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

i18n-watch

The model adds t('checkout.total') to a component, and the key exists in no locale file, or in English only. The page then shows the raw key, or English on the Turkish page, and nobody notices until a user does. This mod tells the model when an edit uses translation keys that one or more locale files lack. The note comes with the Edit's result, so the model adds the keys in the same turn. By default nothing is stopped; in deny mode a commit, a push and a merge stop while a key is missing.

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, .vue, .svelte, .astro, .php, .py, .rb, .erb, .haml, .slim), it reads the translation calls the edit added: those in new_string that old_string does not have, or every call of a Write.
  2. The calls read are t, $t, i18n.t, __, trans, trans_choice, @lang, _, gettext and ngettext with a quoted first argument, also after this., vm., i18n., $i18n., I18n. and i18n.global.. A variable argument, a template literal and a Rails lazy key (t('.title')) are skipped.
  3. It reads the locale files under these directories of the session directory, at most 4 levels deep and 200 files, skipping node_modules and any file over 2 MB: locales, lang, i18n, translations, locale, config/locales, resources/lang, src/locales, src/i18n, public/locales. The session directory is the one the session started in, read once at its start, because a Bash cd moves the session's own directory. The edited file is named against the git repository the session started in, so a session opened in apps/web names a file of apps/api as apps/api/x.ts. Outside a git repository the file is named against the session directory. That root is read once at the session's start too.
FormatExample pathKeys
JSON (i18next, vue-i18n, Laravel)locales/tr.json, locales/tr/checkout.json, lang/tr.jsonnested keys as dotted paths; item_one also defines item
PHP array (Laravel)lang/tr/messages.phpmessages.key, nested arrays as dotted paths
YAML (Rails, Symfony)config/locales/tr.yml, translations/messages.tr.yamldotted paths; a Rails top key (tr:) is left out
gettextlocale/tr/LC_MESSAGES/django.poeach msgid

The language comes from a directory (tr/, en-US/) or from the file name (tr.json, messages.tr.yaml). A key in a namespace file also counts as ns.key and ns:key.

  1. A key is missing when a language lacks it, or when no language has it. Each key is named with the line it is called on, so you can open it. The model reads this note after the Edit's result:

i18n-watch: this edit uses translation keys the locale files lack: checkout.total:42 (missing in tr, de) · checkout.vat:58 (missing in every locale). Add them to each locale file.

At most 10 keys are named, the rest counted. Only the keys this edit added are reported; a key reported before stays in the finding without being said again.

  1. At the same moment one line reaches the transcript, so you see what the model was told. The line holds the file and its keys, without the instruction. The file is named because the model saw the edit and you did not:

i18n-watch: keys src/Cart.vue uses that the locale files lack: checkout.total:42 (missing in tr, de) · checkout.vat:58 (missing in every locale)

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 keys go there instead, as an entry in its stream: the file first, then one line per key, with the key red, its line number faint, every locale red and a list of some locales yellow. The transcript stays clean. 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 is never a remembered answer. The keys it holds are a claim, and every measure reads the source file from disk again and drops the keys it no longer calls. So a finding closes two ways, measured after each Edit and Write, at the end of each main-loop turn, and before a guarded git command:
  • every locale gained the keys;
  • the code stopped calling them, because the edit deleted the string, replaced it with another one or moved it to another file. A file that is gone closes its finding too.

The entry is cleared and a green one says which of the two it was:

i18n-watch: every locale now has the keys src/Cart.vue lacked: checkout.total · checkout.vat i18n-watch: index.php no longer uses: Unauthorized Access

With the sidebar closed the same text is one transcript line. The model reads nothing of this: the finding closed by its own work, so a note would only repeat what it just did. A file that is there and cannot be read keeps its finding, because an unread file proves nothing.

  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:

i18n-watch: 1 file(s) still use translation keys the locale files lack: index.php (Unauthorized Access:42). Add the keys to every locale file, or take the calls 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 uses keys the locale files lack; a command with --dry-run, --help or -h is not stopped. 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 /i18n-watch mode note. note mode is the default and stops nothing, but it measures the findings at a git command all the same, so a settled one does not stay in the pane.

The locale files are read at the first edit of a turn that needs them, and again after an Edit or Write of a locale file. A project without these directories gets nothing. A locale file that cannot be read or parsed is skipped and logged once per session.

In the live check the model added t('cart.total') to a file of a project with locales/en.json and locales/tr.json, read the note after the Edit, and quoted it word for word.

Command

/i18n-watch on or off, the mode, and the files still missing keys /i18n-watch on | off on by default /i18n-watch mode note note only; the default /i18n-watch mode deny a commit, a push and a merge also stop while a key is missing

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install i18n-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=i18n-watch}, turn.start, tool.call{tool=Bash}, turn.complete, prompt.submit, tool.call{tool=Edit}, tool.call{tool=Write} ❯ ./register.ts calls: $.command.register, $.fs.exists (via isDir, usedNow), $.fs.list (via walkLocales), $.fs.read (via loadCatalog, usedNow), $.fs.stat (via isDir), $.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 catalogOf, 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; each reported source file again; the locale directories under the session directory and their files
  2. Runs: git rev-parse --show-toplevel once at the session's start, to name files against the repository root; git rev-parse --show-toplevel and git diff --cached --name-only -z, at a guarded command in deny mode, to read which files the commit holds
  3. Sends: a note to the model after an edit that uses missing keys, 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; the locale keys live in memory for one turn
  5. Hostile input: locale files are only parsed as data (JSON.parse and line regexes), never run; PHP files are not executed

Limits

  • Only the locale directories under the session directory are read. A monorepo whose locales sit in apps/web/src/locales is not seen when the session starts at the repository root.
  • A Laravel PHP file is read line by line, not run: an array opened and closed on one line, a computed key and an included array are not seen.
  • YAML is read by indent. Anchors, aliases and flow mappings ({a: b}) are not followed.
  • A key built at run time (t(name), ` t(a.${b}) `) is not checked.
  • A language code is two letters with an optional region or script (tr, pt_BR, zh-Hant); a three-letter code such as fil is not recognised.
  • A key the edit only moves (it was in old_string too) is not checked, and neither is an edit through Bash.
  • The deny mode has no bypass. When a finding cannot be fixed, you turn the gate off with /i18n-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 (another process staging meanwhile) is measured against what the index held at the read.
  • A finding is closed by the calls the file makes, not by the locale files' own use. A key the code stopped calling is dropped from the finding even when the locale files still lack it, because nothing calls it any more.

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 3 files
hooks/register.ts 356 lines
1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { callKeys, isSource, keyLines, newKeys } from './keys.ts'
3import { addFile, denyText, doneLines, doneLog, doneTitle, isCommit, isGuarded, isLocalePath, isNarrowable, LOCALE_DIRS, LOCALE_EXT, logText, modeOf, noteText, openNote, sectionKey, shownPath, sidebarLines, verdict, type Catalog, type Line, type Lines, type Mode } from './locale.ts'
4
5const ENABLED_KEY = 'enabled'
6const MODE_KEY = 'mode'
7
8const USAGE = 'expects nothing (the status), on, off or mode note | deny'
9
10/** Directory levels read under a locale directory, the directory itself being level 1. */
11const MAX_DEPTH = 4
12
13/** Locale files read in all, so a huge tree does not hold an edit. */
14const MAX_FILES = 200
15
16/** A bigger locale file is skipped. */
17const MAX_BYTES = 2_000_000
18
19/**
20 * One reported file: where it is on disk, the keys reported of it, and the line each key was called on.
21 * The keys are a claim, never an answer: every measure reads the file again and drops the keys it no
22 * longer calls, so a key the code deleted cannot hold a finding open.
23 */
24type Open = { path: string; keys: string[]; lines: Lines }
25
26/**
27 * The catalog of the session directory's locale files, read at the first edit that uses a new key and
28 * dropped at each turn and each edit of a locale file; `reported` makes a read error logged once.
29 * `open` holds each reported file's claim, so an edit that adds the keys, and an edit that stops using
30 * them, both close the finding. `root` is the directory the session started in, and locale files are
31 * looked for under it. `shownRoot` is the git repository it lies in (`shownRootOf`), and a path is shown
32 * against it. Both are read once, because a Bash `cd` moves `$.session.cwd()` away. `enabled` and `mode`
33 * are the settings as the store held them at the last read.
34 */
35type State = { enabled: boolean; mode: Mode; catalog?: Catalog; reported: boolean; open: Map<string, Open>; root?: string; shownRoot?: string; owed: boolean }
36
37/**
38 * Reads the on/off setting and the mode from the store, which every window shares, so a change made in
39 * another window applies here at the next hook that acts on it.
40 */
41async function readSettings($: EngineInterface, state: State): Promise<void> {
42  state.enabled = (await $.store.get(ENABLED_KEY)) !== false
43  state.mode = (await $.store.get(MODE_KEY)) === 'deny' ? 'deny' : 'note'
44}
45
46/**
47 * The git repository the session started in, so a file in a sibling directory of a session opened in a
48 * subdirectory still reads short; the session's own directory where git does not answer.
49 */
50async function shownRootOf($: EngineInterface, cwd: string): Promise<string> {
51  try {
52    const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
53    const root = top.stdout.trim()
54    return top.exitCode === 0 && root !== '' ? root : cwd
55  } catch {
56    // No git here, or the command did not run: paths are shown against the session's directory.
57    return cwd
58  }
59}
60
61/** A locale file and the locale directory it was found under. */
62type Found = { root: string; path: string }
63
64function errorText(err: unknown): string {
65  return err instanceof Error ? err.message : String(err)
66}
67
68async function walkLocales($: EngineInterface, root: string, dir: string, depth: number, out: Found[]): Promise<void> {
69  for (const entry of await $.fs.list(dir)) {
70    if (out.length >= MAX_FILES) return
71    const path = `${dir}/${entry.name}`
72    const isLocale = entry.kind === 'file' && LOCALE_EXT.test(entry.name) && entry.size <= MAX_BYTES
73    if (isLocale) out.push({ root, path })
74    else if (entry.kind === 'dir' && depth < MAX_DEPTH && entry.name !== 'node_modules') await walkLocales($, root, path, depth + 1, out)
75  }
76}
77
78async function isDir($: EngineInterface, path: string): Promise<boolean> {
79  return (await $.fs.exists(path)) && (await $.fs.stat(path)).kind === 'dir'
80}
81
82/** Reads every locale file it finds; a directory or file that fails is named in `errors` and skipped. */
83async function loadCatalog($: EngineInterface, cwd: string): Promise<{ catalog: Catalog; errors: string[] }> {
84  const found: Found[] = []
85  const errors: string[] = []
86  for (const dir of LOCALE_DIRS) {
87    const root = `${cwd}/${dir}`
88    try {
89      if (await isDir($, root)) await walkLocales($, root, root, 1, found)
90    } catch (err) {
91      errors.push(`${dir}: ${errorText(err)}`)
92    }
93  }
94  const catalog: Catalog = new Map()
95  for (const f of found) {
96    try {
97      addFile(catalog, f.path.slice(f.root.length + 1), await $.fs.read(f.path))
98    } catch (err) {
99      errors.push(`${f.path.slice(cwd.length + 1)}: ${errorText(err)}`)
100    }
101  }
102  return { catalog, errors }
103}
104
105/** The directory the session started in, or the current one until `session.start` has recorded it. */
106async function rootOf($: EngineInterface, state: State): Promise<string> {
107  return state.root ?? (await $.session.cwd())
108}
109
110async function catalogOf($: EngineInterface, state: State): Promise<Catalog> {
111  if (state.catalog !== undefined) return state.catalog
112  const { catalog, errors } = await loadCatalog($, await rootOf($, state))
113  if (errors.length > 0 && !state.reported) $.ui.log(`some locale files were not read: ${errors.slice(0, 3).join(' · ')}`)
114  state.reported ||= errors.length > 0
115  state.catalog = catalog
116  return catalog
117}
118
119/**
120 * The finding the person reads: an entry in the shared sidebar's stream while it is open, else the
121 * transcript line, as before. The model's note is another channel and does not change here.
122 */
123async function toPerson($: EngineInterface, key: string, title: string, lines: Line[], line: string): Promise<void> {
124  try {
125    const taken = await $.sidebar.set({ consumer: 'i18n-watch', key: sectionKey(key), title, lines, until: 'stream' })
126    if (taken) return
127  } catch {
128    // The sidebar mod is not installed.
129  }
130  $.ui.log(line)
131}
132
133/** Drops the sidebar entries of one finding, so a key the locales gained leaves no warning behind. */
134async function dropEntry($: EngineInterface, key: string): Promise<void> {
135  try {
136    await $.sidebar.clear({ consumer: 'i18n-watch', key: sectionKey(key) })
137  } catch {
138    // The sidebar mod is not installed.
139  }
140}
141
142/**
143 * The keys a source file calls now, and the line of each, read from disk. `undefined` says the file could
144 * not be measured, and then no key is dropped; a file that is gone answers an empty measure, because the
145 * code it held calls nothing any more.
146 */
147async function usedNow($: EngineInterface, path: string): Promise<{ used: Set<string>; lines: Lines } | undefined> {
148  try {
149    if (!(await $.fs.exists(path))) return { used: new Set(), lines: {} }
150    const text = String(await $.fs.read(path))
151    return { used: callKeys(text), lines: keyLines(text) }
152  } catch {
153    // The file is there and was not read: the finding is left as it stands.
154    return undefined
155  }
156}
157
158/**
159 * Measures one file's claim against the file itself and against the locale files, then writes what still
160 * stands. The finding closes when nothing it named is missing any more, whether the locales gained the
161 * keys or the code stopped calling them, and the person reads one line that says which of the two it was.
162 */
163async function measureFile($: EngineInterface, state: State, file: string, open: Open): Promise<void> {
164  const now = await usedNow($, open.path)
165  const v = verdict(await catalogOf($, state), open.keys, now?.used)
166  const lines = now?.lines ?? open.lines
167  if (v.missing.length > 0) {
168    state.open.set(file, { path: open.path, keys: v.missing.map(m => m.key), lines })
169    return
170  }
171  state.open.delete(file)
172  await dropEntry($, file)
173  await toPerson($, file, doneTitle(v.added, v.gone), doneLines(file, v.added, v.gone), doneLog(file, v.added, v.gone))
174}
175
176/** Measures every finding still open, and reports the ones it closed. */
177async function recheckOpen($: EngineInterface, state: State, skip?: string): Promise<void> {
178  if (!state.enabled || state.open.size === 0) return
179  for (const [file, open] of [...state.open]) {
180    if (file !== skip) await measureFile($, state, file, open)
181  }
182}
183
184/** A locale file changed: the catalog is read again, and every open finding is measured against it. */
185async function afterLocaleEdit($: EngineInterface, state: State, r: ToolCallResult): Promise<ToolCallResult> {
186  await readSettings($, state)
187  state.catalog = undefined
188  await recheckOpen($, state)
189  return r
190}
191
192/** Adds the note to an edit that calls translation keys a locale lacks. */
193async function afterEdit($: EngineInterface, state: State, path: string, before: string, after: string, r: ToolCallResult): Promise<ToolCallResult> {
194  if (r.deny !== undefined || r.isError === true) return r
195  if (isLocalePath(path)) return afterLocaleEdit($, state, r)
196  if (!isSource(path)) return r
197  await readSettings($, state)
198  if (!state.enabled) return r
199  const shown = shownPath(path, state.shownRoot ?? (await rootOf($, state)))
200  // Every other file's finding is measured too, because this edit may have moved a key into one of them.
201  await recheckOpen($, state, shown)
202  const added = newKeys(before, after)
203  const claim = [...new Set([...(state.open.get(shown)?.keys ?? []), ...added])]
204  if (claim.length === 0) return r
205  return noteFor($, state, shown, path, claim, added, r)
206}
207
208/** Writes this file's finding and answers the edit: the note to the model, the line to the person. */
209async function noteFor($: EngineInterface, state: State, shown: string, path: string, claim: string[], added: string[], r: ToolCallResult): Promise<ToolCallResult> {
210  // Measured again here, because a boolean helper does not narrow the result union for the spread below.
211  if (r.deny !== undefined || r.isError === true) return r
212  const held = state.open.get(shown)
213  const now = await usedNow($, path)
214  const v = verdict(await catalogOf($, state), claim, now?.used)
215  const lines = now?.lines ?? {}
216  if (v.missing.length === 0) {
217    if (held !== undefined) await measureFile($, state, shown, { ...held, keys: claim, lines })
218    return r
219  }
220  state.open.set(shown, { path, keys: v.missing.map(m => m.key), lines })
221  // Only the keys this edit added are reported; a key reported before is held open without saying it again.
222  const fresh = v.missing.filter(m => added.includes(m.key))
223  if (fresh.length === 0) return r
224  await toPerson($, shown, 'missing translation keys', sidebarLines(shown, fresh, lines), logText(shown, fresh, lines))
225  return { ...r, context: [...(r.context ?? []), noteText(fresh, lines)] }
226}
227
228/**
229 * The files this commit holds, by absolute path, or undefined when git did not answer. Read before the
230 * command runs, so it is the index as the commit will take it.
231 */
232async function stagedPaths($: EngineInterface, state: State): Promise<Set<string> | undefined> {
233  try {
234    const cwd = await rootOf($, state)
235    const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
236    const staged = await $.process.run(['git', 'diff', '--cached', '--name-only', '-z'], { cwd })
237    if (top.exitCode !== 0 || staged.exitCode !== 0) return undefined
238    const base = top.stdout.trim()
239    return new Set(staged.stdout.split('\0').filter(Boolean).map(p => `${base}/${p}`))
240  } catch {
241    // No git here, or the command did not run: the findings are not narrowed.
242    return undefined
243  }
244}
245
246/**
247 * The findings this command answers for. A `git commit` answers for its own files alone, so a finding of
248 * a file the commit does not hold lets it run. A `push` or a `merge` holds no index to read, so every
249 * finding stands there.
250 */
251async function scopeOf($: EngineInterface, state: State, command: string): Promise<{ file: string; keys: string[]; lines: Lines }[]> {
252  const all = [...state.open].map(([file, open]) => ({ file, keys: open.keys, lines: open.lines, path: open.path }))
253  if (!isCommit(command) || !isNarrowable(command)) return all
254  const staged = await stagedPaths($, state)
255  return staged === undefined ? all : all.filter(o => staged.has(o.path))
256}
257
258/**
259 * The gate of the `deny` mode: it measures every open finding again, so a key the model added and a key
260 * the code stopped calling both close it and the command runs. A file this command holds that still lacks
261 * a key stops it, and there is no bypass.
262 */
263async function gate($: EngineInterface, state: State, command: string): Promise<string | undefined> {
264  if (state.open.size === 0 || !isGuarded(command)) return undefined
265  await readSettings($, state)
266  if (!state.enabled) return undefined
267  // Measured in both modes, so a finding the code or the locales settled does not stand in the pane.
268  state.catalog = undefined
269  await recheckOpen($, state)
270  if (state.mode !== 'deny' || state.open.size === 0) return undefined
271  const scoped = await scopeOf($, state, command)
272  if (scoped.length === 0) {
273    $.ui.log(`${state.open.size} file(s) still lack translation keys, and this command holds none of them`)
274    return undefined
275  }
276  return denyText(scoped)
277}
278
279async function setMode($: EngineInterface, state: State, word: string): Promise<string> {
280  const mode = modeOf(word)
281  if (mode === undefined) return 'mode expects note or deny'
282  await $.store.set(MODE_KEY, mode)
283  state.mode = mode
284  return mode === 'deny' ? 'mode deny: git commit, push and merge stop while a file lacks translation keys' : 'mode note: the keys are only reported'
285}
286
287function statusText(state: State): string {
288  const open = state.open.size === 0 ? 'no file is open' : `${state.open.size} file(s) still lack keys`
289  return `${state.enabled ? 'on' : 'off'} · mode ${state.mode} · ${open}`
290}
291
292async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
293  const word = args.trim()
294  if (word === 'on' || word === 'off') {
295    await $.store.set(ENABLED_KEY, word === 'on')
296    state.enabled = word === 'on'
297    return word === 'on' ? 'on: each edit is checked for translation keys the locale files lack' : 'off: edits are not checked'
298  }
299  if (word.startsWith('mode')) return setMode($, state, word.slice(4).trim())
300  if (word !== '') return USAGE
301  await readSettings($, state)
302  return statusText(state)
303}
304
305export const register: Register = on => {
306  const state: State = { enabled: true, mode: 'note', reported: false, open: new Map(), owed: false }
307
308  on('session.start', async ($, e, next) => {
309    const r = await next(e)
310    await $.command.register({ name: 'i18n-watch', description: 'Translation keys an edit uses that locale files lack: status, on, off, mode (i18n-watch)', argumentHint: '[on | off | mode note | deny]' })
311    await readSettings($, state)
312    const cwd = await $.session.cwd()
313    state.root = cwd
314    state.shownRoot = await shownRootOf($, cwd)
315    return r
316  })
317
318  // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
319  on('command.run', { command: 'i18n-watch' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
320
321  on('turn.start', async (_, e, next) => {
322    state.catalog = undefined
323    return next(e)
324  })
325
326  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
327    const stop = await gate($, state, e.command)
328    return stop === undefined ? next(e) : { deny: stop }
329  })
330
331  /*
332   * The turn's end measures every finding again and owes the model a note for what is left, because a
333   * finding it did not close would otherwise stand in the pane and reach it never again.
334   */
335  on('turn.complete', async ($, e, next) => {
336    const r = await next(e)
337    if (e.agentId !== undefined) return r
338    state.catalog = undefined
339    if (state.open.size > 0) await readSettings($, state)
340    await recheckOpen($, state)
341    state.owed = state.open.size > 0
342    return r
343  })
344
345  // The note goes to the model alone; the person reads the pane, which carries the same finding.
346  on('prompt.submit', async (_, e, next) => {
347    if (!state.owed || state.open.size === 0) return next(e)
348    state.owed = false
349    const note = openNote([...state.open].map(([file, open]) => ({ file, keys: open.keys, lines: open.lines })))
350    return next({ ...e, context: [...(e.context ?? []), note] })
351  })
352
353  on('tool.call', { tool: 'Edit' }, async ($, e, next) => afterEdit($, state, e.file_path, e.old_string, e.new_string, await next(e)))
354  on('tool.call', { tool: 'Write' }, async ($, e, next) => afterEdit($, state, e.file_path, '', e.content, await next(e)))
355}
356
hooks/keys.ts 53 lines
1/** Translation keys named by the calls in a piece of source code. */
2
3/** Files whose calls are read; a locale file, a README or a config file is not. */
4const SOURCE = /\.(ts|tsx|js|jsx|mjs|cjs|vue|svelte|astro|php|py|rb|erb|haml|slim)$/
5
6/**
7 * A translation call with a quoted first argument: `t`, `$t`, `i18n.t`, `__`, `trans`, `trans_choice`,
8 * `@lang`, `_`, `gettext` and `ngettext`, also after `this.`, `vm.`, `i18n.`, `$i18n.`, `I18n.` or `i18n.global.`.
9 * A call on any other object, a variable argument and a template literal do not match.
10 */
11const CALL = /(?:^|[^\w$.@])(?:(?:this|vm|I18n|i18n\.global|i18n|\$i18n)\.)?(?:\$t|t|__|trans_choice|trans|_|gettext|ngettext|@lang)\(\s*(['"])((?:\\.|(?!\1)[^\\\n])+)\1/gm
12
13/** Undoes backslash escapes: `\n` and `\t` become the characters, any other escaped character itself. */
14export function unescape(text: string): string {
15  return text.replace(/\\(.)/g, (_, c: string) => (c === 'n' ? '\n' : c === 't' ? '\t' : c))
16}
17
18export function isSource(path: string): boolean {
19  return SOURCE.test(path)
20}
21
22/** The keys of the calls in `text`; a Rails lazy key (`.title`) is skipped, because its full name depends on the view. */
23export function callKeys(text: string): Set<string> {
24  const keys = new Set<string>()
25  for (const m of text.matchAll(CALL)) {
26    const key = unescape(m[2] ?? '')
27    if (key.trim() !== '' && !key.startsWith('.')) keys.add(key)
28  }
29  return keys
30}
31
32/** The keys `after` calls that `before` does not. */
33export function newKeys(before: string, after: string): string[] {
34  const old = callKeys(before)
35  return [...callKeys(after)].filter(k => !old.has(k))
36}
37
38/** The 1-based line of each key's first call in `text`, so a finding names where the key is. */
39export function keyLines(text: string): Record<string, number> {
40  const out: Record<string, number> = {}
41  let at = 0
42  let line = 1
43  for (const m of text.matchAll(CALL)) {
44    // The key's own end, because the match starts one character before the call and a call can span lines.
45    const end = (m.index ?? 0) + m[0].length
46    for (let i = at; i < end; i += 1) if (text[i] === '\n') line += 1
47    at = end
48    const key = unescape(m[2] ?? '')
49    if (key.trim() !== '' && !key.startsWith('.') && out[key] === undefined) out[key] = line
50  }
51  return out
52}
53
hooks/locale.ts 304 lines
1/** The keys each language's locale files define, and the keys an edit uses that a language lacks. */
2import { unescape } from './keys.ts'
3
4/** Directories under the session directory that hold locale files. */
5export const LOCALE_DIRS = ['locales', 'lang', 'i18n', 'translations', 'locale', 'config/locales', 'resources/lang', 'src/locales', 'src/i18n', 'public/locales']
6
7export const LOCALE_EXT = /\.(json|php|ya?ml|po)$/
8
9/** A language code: two letters, with an optional region or script (`tr`, `en-US`, `pt_BR`, `zh-Hant`). */
10const LANG = /^[a-z]{2}(?:[_-][A-Za-z]{2,4})?$/
11
12/** Defined keys per language code. */
13export type Catalog = Map<string, Set<string>>
14
15/** A key and the languages that lack it, `all` when no language has it. */
16export type Missing = { key: string; langs: string[] | 'all' }
17
18/** Whether an edit of `path` can change the catalog. */
19export function isLocalePath(path: string): boolean {
20  return LOCALE_EXT.test(path) && LOCALE_DIRS.some(d => path.includes(`/${d}/`))
21}
22
23/**
24 * The language of a locale file and its namespace, from its path relative to the locale directory:
25 * `tr/messages.php` and `tr/LC_MESSAGES/django.po` by directory, `tr.json` and `messages.tr.yaml` by name.
26 */
27export function fileLang(rel: string): { lang: string; ns?: string } | undefined {
28  const parts = rel.split('/')
29  const stem = (parts.pop() ?? '').replace(LOCALE_EXT, '')
30  const dirLang = parts.find(p => LANG.test(p))
31  if (dirLang !== undefined) return { lang: dirLang, ns: stem }
32  const dotted = stem.split('.')
33  const lang = dotted.pop() ?? ''
34  if (!LANG.test(lang)) return undefined
35  return dotted.length > 0 ? { lang, ns: dotted.join('.') } : { lang }
36}
37
38/** i18next plural forms: `item_one` also defines `item`, the key `t('item', { count })` names. */
39const PLURAL = /_(zero|one|two|few|many|other)$/
40
41function flatten(value: unknown, prefix: string, out: string[]): void {
42  if (prefix !== '') out.push(prefix)
43  if (value === null || typeof value !== 'object' || Array.isArray(value)) return
44  for (const [k, v] of Object.entries(value)) flatten(v, prefix === '' ? k : `${prefix}.${k}`, out)
45}
46
47/** Nested JSON keys as dotted paths; a parse error throws. */
48export function jsonKeys(text: string): string[] {
49  const out: string[] = []
50  flatten(JSON.parse(text) as unknown, '', out)
51  return out.flatMap(k => (PLURAL.test(k) ? [k, k.replace(PLURAL, '')] : [k]))
52}
53
54const PHP_KEY = /^\s*(['"])(.+?)\1\s*=>\s*(.*)$/
55const PHP_OPEN = /^(\[|array\()\s*(\/\/.*)?$/
56const PHP_CLOSE = /^\s*[\])]/
57
58/** Keys of a PHP array file, one `'key' => ...` per line; an array that opens at the end of a line nests. */
59export function phpKeys(text: string): string[] {
60  const stack: string[] = []
61  const out: string[] = []
62  for (const line of text.split('\n')) {
63    const m = PHP_KEY.exec(line)
64    if (m === null) {
65      if (PHP_CLOSE.test(line)) stack.pop()
66      continue
67    }
68    const key = m[2] ?? ''
69    out.push([...stack, key].join('.'))
70    if (PHP_OPEN.test((m[3] ?? '').trim())) stack.push(key)
71  }
72  return out
73}
74
75const YAML_KEY = /^( *)(["']?)([^\s#'"-][^:]*?)\2:(?:\s|$)/
76
77/** Keys of a YAML mapping by indent; a Rails file's top key, the language, is also left out. */
78export function yamlKeys(text: string, lang: string): string[] {
79  const stack: { indent: number; key: string }[] = []
80  const out: string[] = []
81  for (const line of text.split('\n')) {
82    const m = YAML_KEY.exec(line)
83    if (m === null) continue
84    const indent = (m[1] ?? '').length
85    while (stack.length > 0 && (stack.at(-1)?.indent ?? 0) >= indent) stack.pop()
86    stack.push({ indent, key: m[3] ?? '' })
87    out.push(stack.map(s => s.key).join('.'))
88  }
89  return out.flatMap(k => (k.startsWith(`${lang}.`) ? [k, k.slice(lang.length + 1)] : [k]))
90}
91
92/** The `msgid` strings of a gettext file, with continuation lines joined. */
93export function poKeys(text: string): string[] {
94  const out: string[] = []
95  let current: string | undefined
96  for (const line of text.split('\n')) {
97    const id = /^msgid\s+"(.*)"\s*$/.exec(line)
98    const more = /^"(.*)"\s*$/.exec(line)
99    if (id !== null) current = unescape(id[1] ?? '')
100    else if (more !== null && current !== undefined) current += unescape(more[1] ?? '')
101    else {
102      if (current) out.push(current)
103      current = undefined
104    }
105  }
106  if (current) out.push(current)
107  return out
108}
109
110function fileKeys(rel: string, text: string, lang: string): string[] {
111  if (rel.endsWith('.json')) return jsonKeys(text)
112  if (rel.endsWith('.php')) return phpKeys(text)
113  if (rel.endsWith('.po')) return poKeys(text)
114  return yamlKeys(text, lang)
115}
116
117/** Adds one locale file; a key of a namespace file is also added as `ns.key` and `ns:key`. A JSON parse error throws. */
118export function addFile(catalog: Catalog, rel: string, text: string): void {
119  const where = fileLang(rel)
120  if (where === undefined) return
121  // Keys first, so a file that does not parse adds no empty language.
122  const keys = fileKeys(rel, text, where.lang)
123  const set = catalog.get(where.lang) ?? new Set<string>()
124  catalog.set(where.lang, set)
125  for (const key of keys) {
126    set.add(key)
127    if (where.ns !== undefined) set.add(`${where.ns}.${key}`).add(`${where.ns}:${key}`)
128  }
129}
130
131/** The keys some language lacks; nothing when the catalog has no language. */
132export function missingKeys(catalog: Catalog, keys: string[]): Missing[] {
133  const langs = [...catalog.keys()].sort()
134  const out: Missing[] = []
135  for (const key of keys) {
136    const lacking = langs.filter(l => catalog.get(l)?.has(key) !== true)
137    if (lacking.length > 0) out.push({ key, langs: lacking.length === langs.length ? 'all' : lacking })
138  }
139  return out
140}
141
142/** At most this many keys are named in the note, the rest counted. */
143const MAX_NAMED = 10
144
145/** Where each key is called, by line, so a finding says which line holds it. */
146export type Lines = Record<string, number>
147
148/** One key as every text names it: the key, and the line it is called on when that was measured. */
149function at(key: string, lines: Lines): string {
150  const line = lines[key]
151  return line === undefined ? key : `${key}:${line}`
152}
153
154function namedKeys(missing: Missing[], lines: Lines): string {
155  const named = missing.slice(0, MAX_NAMED).map(m => `${at(m.key, lines)} (missing in ${m.langs === 'all' ? 'every locale' : m.langs.join(', ')})`)
156  if (missing.length > MAX_NAMED) named.push(`${missing.length - MAX_NAMED} more`)
157  return named.join(' · ')
158}
159
160export function noteText(missing: Missing[], lines: Lines): string {
161  return `i18n-watch: this edit uses translation keys the locale files lack: ${namedKeys(missing, lines)}. Add them to each locale file.`
162}
163
164/**
165 * The transcript line: the file and its keys, without the instruction the model reads. The engine adds
166 * the mod name. The file is named because the person, unlike the model, did not see the edit.
167 */
168export function logText(file: string, missing: Missing[], lines: Lines): string {
169  return `keys ${file} uses that the locale files lack: ${namedKeys(missing, lines)}`
170}
171
172/** How the sidebar colours a line or a part of one. */
173type Tone = 'ok' | 'warn' | 'error' | 'dim'
174export type Part = { text: string; kind?: Tone }
175/** A sidebar line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
176export type Line = { text: string; kind?: Tone; parts?: Part[] }
177
178const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
179
180/** A line made of parts, its `text` their texts joined. */
181const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
182
183/** One missing key's row: the key red, its line faint, every locale red and a partial list of locales yellow. */
184function missingLine(m: Missing, lines: Lines): Line {
185  const line = lines[m.key]
186  const where = line === undefined ? [] : [part(`:${line}`, 'dim')]
187  const langs = m.langs === 'all' ? part('every locale', 'error') : part(m.langs.join(', '), 'warn')
188  return partsLine([part(m.key, 'error'), ...where, part(' (missing in ', 'dim'), langs, part(')', 'dim')])
189}
190
191/** The sidebar lines of a finding: the file, then one line per missing key, as the closing lines read. */
192export function sidebarLines(file: string, missing: Missing[], lines: Lines): Line[] {
193  const rows = missing.slice(0, MAX_NAMED).map(m => missingLine(m, lines))
194  if (missing.length > MAX_NAMED) rows.push({ text: `${missing.length - MAX_NAMED} more`, kind: 'dim' })
195  return [{ text: file, kind: 'error' }, ...rows]
196}
197
198function namedPlain(keys: string[]): string {
199  const named = keys.slice(0, MAX_NAMED)
200  if (keys.length > MAX_NAMED) named.push(`${keys.length - MAX_NAMED} more`)
201  return named.join(' · ')
202}
203
204/**
205 * What a reported file's keys read as now: the ones a locale still lacks, the ones the code stopped
206 * calling, and the ones every locale gained. `used` is the keys the file calls now, or undefined when
207 * the file could not be read, where every key counts as still called.
208 */
209export type Verdict = { missing: Missing[]; gone: string[]; added: string[] }
210
211export function verdict(catalog: Catalog, claim: readonly string[], used: Set<string> | undefined): Verdict {
212  const gone = used === undefined ? [] : claim.filter(k => !used.has(k))
213  const alive = claim.filter(k => !gone.includes(k))
214  const missing = alive.length === 0 ? [] : missingKeys(catalog, alive)
215  const added = alive.filter(k => !missing.some(m => m.key === k))
216  return { missing, gone, added }
217}
218
219/** The title of a closed finding, by what closed it. */
220export function doneTitle(added: readonly string[], gone: readonly string[]): string {
221  if (gone.length === 0) return 'translation keys added'
222  return added.length === 0 ? 'translation keys gone' : 'translation keys resolved'
223}
224
225function doneParts(file: string, added: string[], gone: string[]): string[] {
226  const parts: string[] = []
227  if (added.length > 0) parts.push(`every locale now has the keys ${file} lacked: ${namedPlain(added)}`)
228  if (gone.length > 0) parts.push(`${file} no longer uses: ${namedPlain(gone)}`)
229  return parts
230}
231
232/** The transcript line of a finding that closed: the keys the locales gained, the keys the code dropped. */
233export function doneLog(file: string, added: string[], gone: string[]): string {
234  return doneParts(file, added, gone).join(' · ')
235}
236
237/** The sidebar lines of a closed finding: the file, then each key with what happened to it. */
238export function doneLines(file: string, added: string[], gone: string[]): Line[] {
239  const addedRows = added.slice(0, MAX_NAMED).map((text): Line => ({ text, kind: 'ok' }))
240  const goneRows = gone.slice(0, MAX_NAMED).map(k => partsLine([part(k, 'ok'), part(' (no longer used)', 'dim')]))
241  return [{ text: file, kind: 'ok' }, ...addedRows, ...goneRows]
242}
243
244/** The global flags git takes before the subcommand, so `git -c user.name=x commit` is still a commit. */
245const 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))`
246
247/** A `git commit`, `git push` or `git merge` the gate stops while a finding is open. */
248const GUARDED = new RegExp(String.raw`(^|[\s;&|(])git(?:${GIT_FLAG})*\s+(commit|push|merge)\b`)
249const ASKING = /\s(--dry-run|--help|-h)(\s|$)/
250
251export function isGuarded(command: string): boolean {
252  return GUARDED.test(command) && !ASKING.test(command)
253}
254
255/** Whether the command is a `git commit`, the one guarded command whose own files can be measured. */
256export function isCommit(command: string): boolean {
257  return GUARDED.exec(command)?.[2] === 'commit'
258}
259
260/**
261 * Whether the index alone says what this commit holds. A `-a` or `-am` commit stages the tracked files
262 * as it runs, and a pathspec after `--` commits paths the index does not hold, so neither is narrowed.
263 */
264export function isNarrowable(command: string): boolean {
265  const words = command.split(/\s+/)
266  return !words.includes('--') && !words.some(w => w === '--all' || /^-[A-Za-z]*a/.test(w))
267}
268
269/** The mode of the mod: a note only, or a note and a gate on git commit, push and merge. */
270export type Mode = 'note' | 'deny'
271
272/** The mode a `/i18n-watch mode <word>` argument names, or undefined when it is not one. */
273export function modeOf(arg: string): Mode | undefined {
274  return arg === 'note' || arg === 'deny' ? arg : undefined
275}
276
277/** The deny text both the model and the person read: which file lacks which keys, and the one way out. */
278export function denyText(open: readonly { file: string; keys: string[]; lines: Lines }[]): string {
279  const named = open.slice(0, MAX_NAMED).map(o => `${o.file} (${namedPlain(o.keys.map(k => at(k, o.lines)))})`)
280  if (open.length > MAX_NAMED) named.push(`${open.length - MAX_NAMED} more`)
281  return `stopped: ${open.length} file(s) use translation keys the locale files lack: ${named.join(' · ')}. Add the keys to every locale file, then run the command again; there is no way around this gate.`
282}
283
284/**
285 * The note the model reads at the next prompt while a finding stands, so a finding it did not close
286 * reaches it again instead of standing in the pane alone. The person reads the pane and needs no line.
287 */
288export function openNote(open: readonly { file: string; keys: string[]; lines: Lines }[]): string {
289  const named = open.slice(0, MAX_NAMED).map(o => `${o.file} (${namedPlain(o.keys.map(k => at(k, o.lines)))})`)
290  if (open.length > MAX_NAMED) named.push(`${open.length - MAX_NAMED} more`)
291  return `i18n-watch: ${open.length} file(s) still use translation keys the locale files lack: ${named.join(' · ')}. Add the keys to every locale file, or take the calls out.`
292}
293
294/** `path` shown relative to the session's directory when it is inside it. */
295export function shownPath(path: string, cwd: string): string {
296  const base = `${cwd.replace(/\/+$/, '')}/`
297  return path.startsWith(base) ? path.slice(base.length) : path
298}
299
300/** A sidebar section key: the subject cut to what the sidebar takes, so one file keeps one section. */
301export function sectionKey(text: string): string {
302  return text.replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64) || 'note'
303}
304