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.

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.
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.cd before the commit and the commit's git -C, and records HEAD.HEAD, it reads the first reference file at the root: .env.example, else .env.sample, else .env.dist. A repository without one gets nothing.git show --format= --unified=0 HEAD and looks for these env reads:| Language | Reads |
|---|---|
| JavaScript, TypeScript | process.env.X, process.env['X'], import.meta.env.X |
| Python | os.getenv('X'), os.environ['X'], os.environ.get('X'), getenv('X') |
| Go | os.Getenv("X"), os.LookupEnv("X") |
| PHP, Laravel | env('X'), getenv('X'), $_ENV['X'], $_SERVER['X'] |
| Rust | std::env::var("X"), env::var("X"), env::var_os("X") |
| Ruby | ENV['X'], ENV.fetch('X') |
| Java, Kotlin | System.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.
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.
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.
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.
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.
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.
/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
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.
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.
config('x'), a settings class, dotenv schema files) is not seen, and neither is a name built at run time (process.env[name])..env.example is checked against the root file.git commit is not seen and passes the gate.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.deny mode has no bypass. When a finding cannot be fixed, you turn the gate off with /env-sync mode note.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.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.ts 292 lines1import 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}
292hooks/env.ts 237 lines1/** 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