SLOPSHOPPER

env-sync

After each commit the model makes, names the env variables its added lines read that .env.example lacks, with the file and line of each.

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

env-sync

The model adds process.env.STRIPE_KEY to the code, commits, and .env.example still does not mention it. The next person who clones the repository starts the app and wonders why payments fail. This mod checks each commit: after every git commit the model runs, it reads the lines the commit added, and adds the env variables the reference file does not list to the commit's result. The commit itself is never stopped.

What it does

  1. It watches the Bash tool. A command that runs git commit is checked, also git -C <dir> commit or with git's global flags in front, but not with --dry-run, --help or -h.
  2. Before the command runs, it finds the repository root from the session's directory, the last cd before the commit and the commit's git -C, and records HEAD.
  3. After a successful command that moved HEAD, it reads the first reference file at the root: .env.example, else .env.sample, else .env.dist. A repository without one gets nothing.
  4. It reads the added lines with git show --format= --unified=0 HEAD and looks for these env reads:
LanguageReads
JavaScript, TypeScriptprocess.env.X, process.env['X'], import.meta.env.X
Pythonos.getenv('X'), os.environ['X'], os.environ.get('X'), getenv('X')
Goos.Getenv("X"), os.LookupEnv("X")
PHP, Laravelenv('X'), getenv('X'), $_ENV['X'], $_SERVER['X']
Ruststd::env::var("X"), env::var("X"), env::var_os("X")
RubyENV['X'], ENV.fetch('X')
Java, KotlinSystem.getenv("X")

A name is upper case ([A-Z][A-Z0-9_]*). NODE_ENV, HOME, PATH, USER, PWD, SHELL, TMPDIR, TERM, LANG and CI are skipped, and so are the request values a web server sets in $_SERVER (HTTP_*, REQUEST_*, SERVER_* and the like, plus HTTPS, AUTH_TYPE and UNIQUE_ID, which carry no prefix). Lines of prose files (.md, .txt, .rst and the like) are not read.

  1. A variable counts as listed when the reference file has an X=, export X= or commented # X= line. For the rest, the model reads this note right after the commit's result:

env-sync: this commit reads env variables .env.example lacks: STRIPE_KEY (src/pay.ts:12) · REDIS_URL (app/cache.py:4). Add them to .env.example with a placeholder value, never a real secret.

Each variable is named once, at its first added line. At most 10 are named, the rest are counted.

  1. At the same moment you get one line in the transcript, so you see what the model was told. It holds the variables alone, without the instruction:

env-sync: env variables .env.example lacks: STRIPE_KEY (src/pay.ts:12) · REDIS_URL (app/cache.py:4)

The note and the line are separate channels: the model never reads the line, and you never read the note.

  1. With the sidebar open, the variables go into its stream instead, one line per variable (the name red, where it is read faint), and the transcript stays clean. The entry stays until newer ones push it off the pane. Without the sidebar, the line lands in the transcript as above.
  2. A finding is never a remembered answer. Each measure, after every later commit and before a guarded git command, reads both sources again, so a finding closes in two ways:
  • the reference file now lists the variable;
  • the file whose added lines read it no longer reads it, because the code was changed or reverted. A file that is gone reads nothing either.

A variable that settled leaves the finding at once, and the entry is cleared when nothing is left. A green entry says why:

env-sync: .env.example now lists the variables it lacked: STRIPE_KEY · REDIS_URL env-sync: the code no longer reads: STRIPE_KEY

With the sidebar closed the same text is one transcript line. The model reads none of this: the finding closed through its own work, so a note would only repeat what it just did. A file that is there but cannot be read keeps its variable open, 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 whatever is left reaches the model as one note with your next prompt:

env-sync: .env.example still lacks 1 env variable(s) the code reads: STRIPE_KEY (src/pay.ts). Add them to .env.example with a placeholder value, or take the reads out.

That is one note per turn, not one per prompt. Without it the finding would be said once, at the commit, and then sit in the pane while the model forgot about it. You read nothing new, because the pane already shows the same finding.

  1. In deny mode the mod also stops git commit, git push and git merge while a finding is open. Before it stops one it measures both sources again, so a commit that added the variables, or one that took the reads out, opens the gate by 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 files that read the missing variables, with one line telling you how many still stand. A push and a merge have no index to read, so every finding counts there. There is no bypass; only you turn the gate off, with /env-sync mode note. note mode is the default and stops nothing.

A git error is written as a yellow entry (a transcript line with the sidebar closed), once until a different error comes, and the commit's result stays as it was.

In the live check the model added process.env.STRIPE_KEY to a file in a repository whose .env.example listed only DB_URL, committed it, and quoted the note word for word.

Command

/env-sync on or off, the mode, and the variables still missing /env-sync on | off on by default /env-sync mode note note only; the default /env-sync mode deny a commit, a push and a merge also stop while a variable is missing

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install env-sync@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=env-sync}, turn.complete, prompt.submit, tool.call{tool=Bash} ❯ ./register.ts calls: $.command.register, $.fs.exists (via referenceFile, stillRead), $.fs.read (via commitNote, gate, recheckNow, stillRead), $.process.run (via git, scopeOf), $.session.cwd (via beforeCommit, recheckNow), $.sidebar.clear (via dropEntry), $.sidebar.set (via toPerson), $.store.get (via readSettings), $.store.set (via runCommand, setMode), $.ui.log (via denyFor, toPerson)

Reach L2: it runs processes.

  1. Reads: the Bash command text; the reference file at the repository root; each file an open finding came from, again, also at the turn's end; through git, the commit's added lines
  2. Runs: git rev-parse, git show and git diff --cached --name-only -z, read-only, by argv, at most four times per commit, and git rev-parse at the turn's end while a finding stands
  3. Sends: a note to the model after the commit's result, 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 directory comes from the command text and reaches git only as the working directory, never through a shell; the note names variables, never a value from .env.example

Limits

  • A variable read through a config layer (Laravel config('x'), a settings class, dotenv schema files) is not seen, and neither is a name built at run time (process.env[name]).
  • Only the reference file at the repository root is read. A monorepo package with its own .env.example is checked against the root file.
  • The mod reads the command as text, so a commit through a script or an alias that hides git commit is not seen and passes the gate.
  • A cd or git -C whose directory the shell expands first (cd $D, cd ~/x, a backquote) names no directory the mod can tell. That commit is not checked, and the yellow line names the word, for example the commit's directory is not known: cd $D. A single-quoted word stays literal.
  • A merge commit's combined diff is not read.
  • The deny mode has no bypass. When a finding cannot be fixed, you turn the gate off with /env-sync mode note.
  • 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 counts for those.
  • A finding is measured against the file the commit read the variable in. A read moved to another file counts as gone there, and the commit that adds it elsewhere reports it again.

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 292 lines
1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { commitDir, denyText, diffReads, doneLines, doneLog, doneTitle, fileReads, isCommit, isGuarded, isNarrowable, listedNames, logText, modeOf, noteText, openNote, openReads, REFERENCE_FILES, sectionKey, sidebarLines, type Line, type Mode, type Open } from './env.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/**
10 * The on/off setting and the mode as the store held them at the last read, and the last error logged,
11 * so the same one is logged once. `open` holds the variables the last finding named, so a commit that
12 * adds them all closes it, and in `deny` mode it also holds the gate shut. `owed` is the reference file
13 * the model is owed a note against, set at the turn's end while the finding stands.
14 */
15type State = { enabled: boolean; mode: Mode; lastError?: string; open: Open[]; owed?: string }
16
17/**
18 * Reads the on/off setting and the mode from the store, which every window shares, so a change made in
19 * another window applies here at the next hook that acts on it.
20 */
21async function readSettings($: EngineInterface, state: State): Promise<void> {
22  state.enabled = (await $.store.get(ENABLED_KEY)) !== false
23  state.mode = modeOf(String(await $.store.get(MODE_KEY))) ?? 'note'
24}
25
26/** The repository and its HEAD before the commit; `head` is empty before the first commit. */
27type Before = { root: string; head: string }
28
29function errorText(err: unknown): string {
30  return err instanceof Error ? err.message : String(err)
31}
32
33/**
34 * Writes an error once until a different one comes: a yellow entry in the sidebar's stream while it is
35 * open, else the transcript line.
36 */
37async function report($: EngineInterface, state: State, err: unknown): Promise<void> {
38  const text = errorText(err)
39  if (text === state.lastError) return
40  state.lastError = text
41  const line = `the commit's env reads were not checked: ${text}`
42  await toPerson($, 'unchecked', 'not checked', [{ text: line, kind: 'warn' }], line)
43}
44
45async function git($: EngineInterface, root: string, args: string[]): Promise<{ ok: boolean; out: string }> {
46  const r = await $.process.run(['git', ...args], { cwd: root, timeoutMs: 10_000 })
47  return { ok: r.exitCode === 0, out: r.stdout }
48}
49
50/** The repository root and HEAD, or undefined outside a repository. */
51async function beforeCommit($: EngineInterface, state: State, command: string): Promise<Before | undefined> {
52  try {
53    const top = await git($, commitDir(command, await $.session.cwd()), ['rev-parse', '--show-toplevel'])
54    if (!top.ok) return undefined
55    const root = top.out.trim()
56    const head = await git($, root, ['rev-parse', 'HEAD'])
57    return { root, head: head.ok ? head.out.trim() : '' }
58  } catch (err) {
59    await report($, state, err)
60    return undefined
61  }
62}
63
64/** The first reference file at the root, or undefined when the repository has none. */
65async function referenceFile($: EngineInterface, root: string): Promise<string | undefined> {
66  for (const name of REFERENCE_FILES) if (await $.fs.exists(`${root}/${name}`)) return name
67  return undefined
68}
69
70/**
71 * The finding the person reads: an entry in the shared sidebar's stream while it is open, else the
72 * transcript line, as before. The model's note is another channel and does not change here.
73 */
74async function toPerson($: EngineInterface, reference: string, title: string, lines: Line[], line: string): Promise<void> {
75  try {
76    const taken = await $.sidebar.set({ consumer: 'env-sync', key: sectionKey(reference), title, lines, until: 'stream' })
77    if (taken) return
78  } catch {
79    // The sidebar mod is not installed.
80  }
81  $.ui.log(line)
82}
83
84/** Drops the sidebar entries of one finding, so a variable that was added leaves no warning behind. */
85async function dropEntry($: EngineInterface, reference: string): Promise<void> {
86  try {
87    await $.sidebar.clear({ consumer: 'env-sync', key: sectionKey(reference) })
88  } catch {
89    // The sidebar mod is not installed.
90  }
91}
92
93/**
94 * Whether the file whose added lines read this variable still reads it. A file that is no longer there
95 * reads nothing; one that is there and cannot be read counts as still reading, because it proves nothing.
96 */
97async function stillRead($: EngineInterface, root: string, open: Open): Promise<boolean> {
98  const path = `${root}/${open.file}`
99  try {
100    if (!(await $.fs.exists(path))) return false
101    return fileReads(String(await $.fs.read(path))).has(open.name)
102  } catch {
103    return true
104  }
105}
106
107/**
108 * Measures the open finding again and closes it when nothing it named stands: the reference file gained
109 * the variable, or the code stopped reading it. Each measure reads the source again, so the finding is a
110 * claim and never an answer.
111 */
112async function recheckOpen($: EngineInterface, state: State, root: string, reference: string, listed: ReadonlySet<string>): Promise<void> {
113  if (state.open.length === 0) return
114  const added: string[] = []
115  const gone: string[] = []
116  const left: Open[] = []
117  for (const open of state.open) {
118    if (listed.has(open.name)) added.push(open.name)
119    else if (await stillRead($, root, open)) left.push(open)
120    else gone.push(open.name)
121  }
122  state.open = left
123  if (left.length > 0 || added.length + gone.length === 0) return
124  await dropEntry($, reference)
125  await toPerson($, reference, doneTitle(added, gone, reference), doneLines(added, gone), doneLog(added, gone, reference))
126}
127
128/**
129 * Measures the open finding outside a commit: the repository root and its reference file are read again.
130 * It answers the reference file while the finding still stands, and undefined when nothing is left, no
131 * repository holds this directory, or the repository has no reference file.
132 */
133async function recheckNow($: EngineInterface, state: State): Promise<string | undefined> {
134  try {
135    const top = await git($, await $.session.cwd(), ['rev-parse', '--show-toplevel'])
136    if (!top.ok) return undefined
137    const root = top.out.trim()
138    const reference = await referenceFile($, root)
139    if (reference === undefined) return undefined
140    await recheckOpen($, state, root, reference, listedNames(await $.fs.read(`${root}/${reference}`)))
141    return state.open.length === 0 ? undefined : reference
142  } catch (err) {
143    await report($, state, err)
144    return undefined
145  }
146}
147
148/** The note for the commit that moved HEAD, or undefined when it reads no variable the reference file lacks. */
149async function commitNote($: EngineInterface, state: State, before: Before): Promise<string | undefined> {
150  const head = await git($, before.root, ['rev-parse', 'HEAD'])
151  const reference = await referenceFile($, before.root)
152  if (!head.ok || head.out.trim() === before.head || reference === undefined) return undefined
153  const diff = await git($, before.root, ['show', '--format=', '--unified=0', '--no-color', '--no-ext-diff', 'HEAD'])
154  if (!diff.ok) throw new Error('git show HEAD failed')
155  const listed = listedNames(await $.fs.read(`${before.root}/${reference}`))
156  await recheckOpen($, state, before.root, reference, listed)
157  const missing = diffReads(diff.out).filter(r => !listed.has(r.name))
158  if (missing.length === 0) return undefined
159  state.open = openReads(state.open, missing)
160  // The note goes to the model, the finding to the person: neither reads the other's channel.
161  await toPerson($, reference, `env variables ${reference} lacks`, sidebarLines(missing), logText(missing, reference))
162  return noteText(missing, reference)
163}
164
165async function afterCommit($: EngineInterface, state: State, before: Before, r: ToolCallResult): Promise<ToolCallResult> {
166  if (r.deny !== undefined || r.isError === true) return r
167  try {
168    const note = await commitNote($, state, before)
169    state.lastError = undefined
170    return note === undefined ? r : { ...r, context: [...(r.context ?? []), note] }
171  } catch (err) {
172    await report($, state, err)
173    return r
174  }
175}
176
177/**
178 * The findings this command answers for. A `git commit` answers for its own files alone, so a variable a
179 * file the commit does not hold reads lets it run. A `push` or a `merge` holds no index to read, so every
180 * finding stands there. The index is read before the command runs, as the commit will take it.
181 */
182async function scopeOf($: EngineInterface, state: State, root: string, command: string): Promise<Open[]> {
183  if (!isCommit(command) || !isNarrowable(command)) return [...state.open]
184  try {
185    const staged = await $.process.run(['git', 'diff', '--cached', '--name-only', '-z'], { cwd: root, timeoutMs: 10_000 })
186    if (staged.exitCode !== 0) return [...state.open]
187    const held = new Set(staged.stdout.split('\0').filter(Boolean))
188    return state.open.filter(o => held.has(o.file))
189  } catch {
190    // git did not run: the findings are not narrowed.
191    return [...state.open]
192  }
193}
194
195/** The deny of the findings this command answers for, or undefined when it holds none of their files. */
196async function denyFor($: EngineInterface, state: State, root: string, reference: string, command: string): Promise<{ deny: string } | undefined> {
197  const scoped = await scopeOf($, state, root, command)
198  if (scoped.length > 0) return { deny: denyText(scoped.map(o => o.name), reference) }
199  $.ui.log(`${reference} still lacks ${state.open.length} variable(s), and this command holds none of the files that read them`)
200  return undefined
201}
202
203/**
204 * The gate: in deny mode a commit, push or merge waits while the reference file still lacks a variable.
205 * The reference file is read again first, so a commit that added the variables opens the gate itself.
206 */
207async function gate($: EngineInterface, state: State, command: string): Promise<{ deny: string } | undefined> {
208  if (!state.enabled || state.mode !== 'deny' || state.open.length === 0 || !isGuarded(command)) return undefined
209  try {
210    const before = await beforeCommit($, state, command)
211    const reference = before === undefined ? undefined : await referenceFile($, before.root)
212    if (before === undefined || reference === undefined) return undefined
213    await recheckOpen($, state, before.root, reference, listedNames(await $.fs.read(`${before.root}/${reference}`)))
214    return state.open.length === 0 ? undefined : denyFor($, state, before.root, reference, command)
215  } catch (err) {
216    await report($, state, err)
217    return undefined
218  }
219}
220
221async function setMode($: EngineInterface, state: State, arg: string): Promise<string> {
222  const mode = modeOf(arg)
223  if (mode === undefined) return 'mode expects note or deny'
224  await $.store.set(MODE_KEY, mode)
225  state.mode = mode
226  return mode === 'deny'
227    ? 'mode deny: git commit, push and merge stop while the reference file lacks a variable'
228    : 'mode note: nothing is stopped, the finding reaches the model as a note'
229}
230
231async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
232  const [first = '', second = ''] = args.trim().split(/\s+/)
233  if (first === 'mode') return setMode($, state, second)
234  const word = args.trim()
235  if (word === 'on' || word === 'off') {
236    await $.store.set(ENABLED_KEY, word === 'on')
237    state.enabled = word === 'on'
238    return word === 'on' ? 'on: each commit is checked for env reads .env.example lacks' : 'off: commits are not checked'
239  }
240  if (word !== '') return USAGE
241  await readSettings($, state)
242  const open = state.open.length === 0 ? 'no variable is open' : `${state.open.map(o => o.name).join(' · ')} still missing`
243  return `${state.enabled ? 'on' : 'off'} · mode ${state.mode} · ${open}`
244}
245
246export const register: Register = on => {
247  const state: State = { enabled: true, mode: 'note', open: [] }
248
249  on('session.start', async ($, e, next) => {
250    const r = await next(e)
251    await $.command.register({ name: 'env-sync', description: 'Env variables a commit reads that .env.example lacks: status, on, off, mode note | deny (env-sync)', argumentHint: '[on | off | mode note | mode deny]' })
252    await readSettings($, state)
253    return r
254  })
255
256  // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
257  on('command.run', { command: 'env-sync' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
258
259  /*
260   * The turn's end measures the open finding again and owes the model a note for what is left, because a
261   * finding it did not close would otherwise stand in the pane and reach it never again.
262   */
263  on('turn.complete', async ($, e, next) => {
264    const r = await next(e)
265    if (e.agentId !== undefined || state.open.length === 0) return r
266    await readSettings($, state)
267    if (!state.enabled) return r
268    state.owed = await recheckNow($, state)
269    return r
270  })
271
272  // The note goes to the model alone; the person reads the pane, which carries the same finding.
273  on('prompt.submit', async (_, e, next) => {
274    const reference = state.owed
275    if (reference === undefined || state.open.length === 0) return next(e)
276    state.owed = undefined
277    return next({ ...e, context: [...(e.context ?? []), openNote(state.open, reference)] })
278  })
279
280  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
281    // Only a commit, or a guarded command while a finding is open, acts on a setting.
282    if (!isCommit(e.command) && (state.open.length === 0 || !isGuarded(e.command))) return next(e)
283    await readSettings($, state)
284    const stopped = await gate($, state, e.command)
285    if (stopped !== undefined) return stopped
286    if (!state.enabled || !isCommit(e.command)) return next(e)
287    const before = await beforeCommit($, state, e.command)
288    const r = await next(e)
289    return before === undefined ? r : afterCommit($, state, before, r)
290  })
291}
292
hooks/env.ts 237 lines
1/** Which commands commit, the env variables a commit's added lines read, and which of them the reference file lacks. */
2
3/** The global flags git takes before the subcommand, so `git -c user.name=x commit` is still a commit. */
4const 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))`
5
6/** A `git commit` the model runs, not one it only asks about. */
7const COMMIT = new RegExp(String.raw`(^|[\s;&|(])git(?:${GIT_FLAG})*\s+commit\b`)
8const NOT_A_COMMIT = /\s(--dry-run|--help|-h)(\s|$)/
9
10export function isCommit(command: string): boolean {
11  return COMMIT.test(command) && !NOT_A_COMMIT.test(command)
12}
13
14/** A `git commit`, `git push` or `git merge` the gate stops while a finding is open. */
15const GUARDED = new RegExp(String.raw`(^|[\s;&|(])git(?:${GIT_FLAG})*\s+(commit|push|merge)\b`)
16
17export function isGuarded(command: string): boolean {
18  return GUARDED.test(command) && !NOT_A_COMMIT.test(command)
19}
20
21/** The mode of the mod: a note only, or a note and a gate on git commit, push and merge. */
22export type Mode = 'note' | 'deny'
23
24/** The mode a `/env-sync mode <word>` argument names, or undefined when it is not one. */
25export function modeOf(arg: string): Mode | undefined {
26  return arg === 'note' || arg === 'deny' ? arg : undefined
27}
28
29/** What the deny says: why the command stopped, and the one setting that turns the gate off. */
30export function denyText(open: readonly string[], reference: string): string {
31  return `stopped: ${reference} still lacks ${open.length} variable(s): ${namedPlain(open)}. Add them with a placeholder value and run the command again; there is no way around this gate.`
32}
33
34const unquote = (word: string): string => word.replace(/^(["'])(.*)\1$/, '$2')
35
36/**
37 * A directory word joined to the one before it. A word the shell expands first (`$D`, `~`, a backquote,
38 * outside single quotes) names no directory this text can tell, so it throws rather than run git in a
39 * directory that is not there.
40 */
41function joinDir(base: string, word: string, how: string): string {
42  const expands = !word.startsWith("'") && (/[$`]/.test(word) || word.startsWith('~'))
43  if (expands) throw new Error(`the commit's directory is not known: ${how} ${word}`)
44  const dir = unquote(word)
45  return dir.startsWith('/') ? dir : `${base.replace(/\/+$/, '')}/${dir}`
46}
47
48/**
49 * The directory the commit runs in: the session's directory, moved by each `cd` before the commit in turn (`cd -`
50 * back to the directory before it) and by its `git -C`, because the hook reads the repository before the
51 * command's own `cd` has run.
52 */
53export function commitDir(command: string, cwd: string): string {
54  const commit = COMMIT.exec(command)
55  if (commit === null) return cwd
56  const cds = [...command.slice(0, commit.index).matchAll(/(?:^|[;&|(]\s*)cd\s+("[^"]*"|'[^']*'|[^\s;&|)]+)/g)]
57  const afterCd = cds.reduce(
58    (at, cd) => (cd[1] === '-' ? { dir: at.prev, prev: at.dir } : { dir: joinDir(at.dir, cd[1] ?? '.', 'cd'), prev: at.dir }),
59    { dir: cwd, prev: cwd },
60  ).dir
61  return [...commit[0].matchAll(/-C\s+(\S+)/g)].reduce((dir, c) => joinDir(dir, c[1] ?? '.', 'git -C'), afterCd)
62}
63
64/** The reference files, in the order they are looked for at the repository root. */
65export const REFERENCE_FILES = ['.env.example', '.env.sample', '.env.dist']
66
67const NAME = '([A-Z][A-Z0-9_]*)'
68
69/** One pattern per way a language reads an env variable; group 1 is the name. */
70const READS = [
71  new RegExp(`process\\.env\\.${NAME}\\b`, 'g'),
72  new RegExp(`process\\.env\\[\\s*['"]${NAME}['"]\\s*\\]`, 'g'),
73  new RegExp(`import\\.meta\\.env\\.${NAME}\\b`, 'g'),
74  new RegExp(`\\bos\\.environ(?:\\.get\\(|\\[)\\s*['"]${NAME}['"]`, 'g'),
75  new RegExp(`\\bos\\.(?:getenv|Getenv|LookupEnv)\\(\\s*['"]${NAME}['"]`, 'g'),
76  new RegExp(`(?<![\\w$>.])(?:env|getenv)\\(\\s*['"]${NAME}['"]`, 'g'),
77  new RegExp(`\\$_ENV\\[\\s*['"]${NAME}['"]\\s*\\]`, 'g'),
78  new RegExp(`\\benv::var(?:_os)?\\(\\s*"${NAME}"`, 'g'),
79  new RegExp(`\\bENV(?:\\.fetch\\(|\\[)\\s*['"]${NAME}['"]`, 'g'),
80  new RegExp(`\\bSystem\\.getenv\\(\\s*"${NAME}"`, 'g'),
81]
82
83/** Variables the shell, the OS or the CI sets, which a project does not document. */
84const SYSTEM = new Set(['NODE_ENV', 'HOME', 'PATH', 'USER', 'PWD', 'SHELL', 'TMPDIR', 'TERM', 'LANG', 'CI'])
85
86/** PHP `$_SERVER`, which holds the env variables and also the web server's request values. */
87const SERVER_READ = new RegExp(`\\$_SERVER\\[\\s*['"]${NAME}['"]\\s*\\]`, 'g')
88
89/**
90 * The request values in `$_SERVER`, which the web server sets and are not env variables: the prefixed
91 * families, and the names PHP documents without a prefix (`HTTPS`, `AUTH_TYPE`, Apache's `UNIQUE_ID`).
92 */
93const SERVER_VALUE = /^(?:(?:HTTP|REQUEST|SERVER|REMOTE|SCRIPT|PHP|CONTENT|DOCUMENT|QUERY|GATEWAY|PATH|ORIG|REDIRECT)_|(?:HTTPS|AUTH_TYPE|UNIQUE_ID)$)/
94
95/** Prose files, whose examples are not reads. */
96const PROSE = /\.(md|mdx|markdown|txt|rst|adoc)$/i
97
98/** An env variable a line reads, with where the line is in the committed tree. */
99export type EnvRead = { name: string; file: string; line: number }
100
101/** The variables one line reads. */
102export function lineReads(text: string): string[] {
103  const names = READS.flatMap(re => [...text.matchAll(re)].map(m => m[1] ?? ''))
104  const server = [...text.matchAll(SERVER_READ)].map(m => m[1] ?? '').filter(n => !SERVER_VALUE.test(n))
105  return [...names, ...server].filter(n => n !== '' && !SYSTEM.has(n))
106}
107
108/** The file a `+++` line names, undefined for a deleted file or a prose file. */
109function newFile(line: string): string | undefined {
110  const path = line.slice(4).replace(/^b\//, '')
111  return path === '/dev/null' || PROSE.test(path) ? undefined : path
112}
113
114/** The env reads on the added lines of a `git show --unified=0` diff, each name once at its first place. */
115export function diffReads(diff: string): EnvRead[] {
116  const reads = new Map<string, EnvRead>()
117  let file: string | undefined
118  let line = 0
119  for (const text of diff.split('\n')) {
120    const hunk = /^@@ -\S+ \+(\d+)/.exec(text)
121    if (text.startsWith('+++ ')) file = newFile(text)
122    else if (hunk !== null) line = Number(hunk[1])
123    else if (text.startsWith('+')) {
124      for (const name of file === undefined ? [] : lineReads(text)) if (!reads.has(name)) reads.set(name, { name, file: file ?? '', line })
125      line++
126    }
127  }
128  return [...reads.values()]
129}
130
131/** The names a reference file lists: `X=`, `export X=` and a commented `# X=` count. */
132export function listedNames(text: string): Set<string> {
133  const names = [...text.matchAll(/^[ \t]*(?:#[ \t]*)?(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)[ \t]*=/gm)]
134  return new Set(names.map(m => m[1] ?? ''))
135}
136
137/** At most this many variables are named in the note, the rest counted. */
138const MAX_NAMED = 10
139
140function namedReads(missing: readonly EnvRead[]): string {
141  const named = missing.slice(0, MAX_NAMED).map(r => `${r.name} (${r.file}:${r.line})`)
142  if (missing.length > MAX_NAMED) named.push(`${missing.length - MAX_NAMED} more`)
143  return named.join(' · ')
144}
145
146export function noteText(missing: readonly EnvRead[], reference: string): string {
147  return `env-sync: this commit reads env variables ${reference} lacks: ${namedReads(missing)}. Add them to ${reference} with a placeholder value, never a real secret.`
148}
149
150/** The transcript line: the variables alone, without the instruction the model reads. The engine adds the mod name. */
151export function logText(missing: readonly EnvRead[], reference: string): string {
152  return `env variables ${reference} lacks: ${namedReads(missing)}`
153}
154
155/** How the sidebar colours a line or a part of one. */
156type Tone = 'ok' | 'warn' | 'error' | 'dim'
157export type Part = { text: string; kind?: Tone }
158/** A sidebar line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
159export type Line = { text: string; kind?: Tone; parts?: Part[] }
160
161const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
162
163/** A line made of parts, its `text` their texts joined. */
164const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
165
166/** One sidebar line per variable, so the section reads as a list: the name red, where it is read faint. */
167export function sidebarLines(missing: readonly EnvRead[]): Line[] {
168  const rows = missing.slice(0, MAX_NAMED).map(r => partsLine([part(r.name, 'error'), part(` (${r.file}:${r.line})`, 'dim')]))
169  if (missing.length > MAX_NAMED) rows.push({ text: `${missing.length - MAX_NAMED} more`, kind: 'dim' })
170  return rows
171}
172
173/** Every variable a whole file reads, so a finding is measured against the file it came from. */
174export function fileReads(text: string): Set<string> {
175  return new Set(lineReads(text))
176}
177
178/** One open variable and the file whose added lines read it; the file is the measure that can close it. */
179export type Open = { name: string; file: string }
180
181/** The variables a finding holds open after a new report: the earlier ones and the new ones, each once. */
182export function openReads(before: readonly Open[], missing: readonly EnvRead[]): Open[] {
183  const out = new Map(before.map(o => [o.name, o]))
184  for (const r of missing) if (!out.has(r.name)) out.set(r.name, { name: r.name, file: r.file })
185  return [...out.values()]
186}
187
188function namedPlain(names: readonly string[]): string {
189  const named = names.slice(0, MAX_NAMED)
190  if (names.length > MAX_NAMED) named.push(`${names.length - MAX_NAMED} more`)
191  return named.join(' · ')
192}
193
194/** The title of a closed finding, by what closed it. */
195export function doneTitle(added: readonly string[], gone: readonly string[], reference: string): string {
196  if (gone.length === 0) return `env variables ${reference} gained`
197  return added.length === 0 ? 'env reads gone' : 'env variables settled'
198}
199
200/** The transcript line of a finding that closed: the variables the reference file gained, the reads the code dropped. */
201export function doneLog(added: readonly string[], gone: readonly string[], reference: string): string {
202  const parts: string[] = []
203  if (added.length > 0) parts.push(`${reference} now lists the variables it lacked: ${namedPlain(added)}`)
204  if (gone.length > 0) parts.push(`the code no longer reads: ${namedPlain(gone)}`)
205  return parts.join(' · ')
206}
207
208/** One sidebar line per variable, the ones the file gained and the ones nothing reads any more. */
209export function doneLines(added: readonly string[], gone: readonly string[]): Line[] {
210  const addedRows = added.slice(0, MAX_NAMED).map((text): Line => ({ text, kind: 'ok' }))
211  const goneRows = gone.slice(0, MAX_NAMED).map(n => partsLine([part(n, 'ok'), part(' (no longer read)', 'dim')]))
212  return [...addedRows, ...goneRows]
213}
214
215/**
216 * Whether the index alone says what this commit holds. A `-a` or `-am` commit stages the tracked files
217 * as it runs, and a pathspec after `--` commits paths the index does not hold, so neither is narrowed.
218 */
219export function isNarrowable(command: string): boolean {
220  const words = command.split(/\s+/)
221  return !words.includes('--') && !words.some(w => w === '--all' || /^-[A-Za-z]*a/.test(w))
222}
223
224/**
225 * The note the model reads at the next prompt while a finding stands, so a finding it did not close
226 * reaches it again instead of standing in the pane alone. The person reads the pane and needs no line.
227 */
228export function openNote(open: readonly Open[], reference: string): string {
229  const named = namedPlain(open.map(o => `${o.name} (${o.file})`))
230  return `env-sync: ${reference} still lacks ${open.length} env variable(s) the code reads: ${named}. Add them to ${reference} with a placeholder value, or take the reads out.`
231}
232
233/** A sidebar section key: the subject cut to what the sidebar takes, so one reference file keeps one section. */
234export function sectionKey(text: string): string {
235  return text.replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64) || 'note'
236}
237