SLOPSHOPPER

config-parse

Parses each JSON, YAML, TOML or .env file an Edit or Write touches, notes a parse error at once instead of at the next build, and says so again when a later…

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

config-parse

A missing comma in package.json or a bad indent in a YAML file costs nothing at the moment it is made; you find out at the next build, or when a service refuses to start, often far from the edit that caused it. This mod parses every JSON, YAML, TOML or .env file right after an Edit or Write touches it, and tells the model about a parse error on the spot.

What it does

  1. After each Edit or Write the engine ran, it looks at the path. A .json, .jsonc, .yml, .yaml, .toml, .env or .env.<name> file is parsed; every other file is left alone. Files their own tool reads as JSON with comments (.jsonc, tsconfig*.json, jsconfig*.json, .vscode/*.json, devcontainer.json) may hold // and /* */ comments and trailing commas. Every other JSON file is parsed strictly.
  2. JSON is parsed by the mod itself. A .env file is read line by line: a line that is not empty, not a comment and not KEY=value (an export in front is fine) is the finding, with its line number.
  3. YAML and TOML are parsed by python3 (yaml.load_all with a safe loader, and tomllib.load), with the file path passed as one argv item. Every document of a --- stream is read, and application tags such as !Ref or !vault are accepted, because both are valid YAML. If python or the module is missing, that kind is skipped for the session and one line tells you.
  4. A file that does not parse goes to two channels. The model gets a context note naming the file and the error. You get a red entry in the sidebar stream (the file first, then the error, with the position the parser names, such as line 2 or (line 3, column 5), in yellow), or a transcript line when the sidebar is closed. The file is shown relative to the git repository the session started in, or to the session's directory outside a repository. That root is read once at the session's start, because a Bash cd moves the session's own directory.
  5. When a later edit makes the same file parse again, the standing entry is cleared and a green line says so. That line goes to you only, because the model fixed the file itself. A file deleted while its finding stands closes the same way at the next measure, with <file> is gone, and its parse error with it. A file that is there but cannot be read keeps its finding.
  6. It never denies an edit. The file is written first, then read.
  7. 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:

config-parse: 1 file(s) still do not parse: package.json. Fix them.

That is one note per turn, not one per prompt. Without it the finding would be said once, at the edit, 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 it also stops git commit, git push and git merge while a file does not parse. Before stopping one it parses every open file again, so the command goes through by itself once the model fixed them. 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 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: --dry-run, --help and -h pass, but a real commit of a broken file waits for the fix. note mode is the default and stops nothing.

In the live check a JSON file broken with a trailing comma got the note (Property name must be a string literal), a YAML file broken with a: 1: 2 got the python error, and the next Write of a: 1 closed the finding with parses as YAML again.

Command

/config-parse on or off, the mode, and the files that do not parse /config-parse on | off on by default /config-parse mode note note only; the default /config-parse mode deny a commit, a push and a merge also stop while a file does not parse

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install config-parse@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. Install python3 with PyYAML for the YAML check (python3 -m pip install pyyaml). TOML only needs python 3.11 or newer. Without them those two kinds are skipped; JSON and .env still work.
  2. 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=config-parse}, tool.call{tool=Edit}, tool.call{tool=Write}, turn.complete, prompt.submit, tool.call{tool=Bash} ❯ ./register.ts calls: $.command.register, $.fs.exists (via isGone), $.fs.read (via fileText), $.process.run (via pythonCheck, shownRootOf, stagedPaths), $.session.cwd, $.sidebar.clear (via closeOne), $.sidebar.set (via toPerson), $.store.get (via readSettings), $.store.set (via runCommand, setMode), $.ui.log

Reach L2: it reads files and runs a process.

  1. Reads: the path of each Edit and Write, the command of each Bash call, and the text of the edited JSON and .env files, and each open file again at the turn's end
  2. Runs: python3 -c, by argv, on YAML and TOML files; git rev-parse --show-toplevel once at the session's start, to name files against the repository root; and git rev-parse --show-toplevel plus git diff --cached --name-only -z at a commit in deny mode
  3. Sends: the file name and the parse error to the model, and one more note with the next prompt while a finding stands; nothing leaves the machine
  4. Persists: the on/off setting and the mode in $.store
  5. Hostile input: the path comes from the tool call and reaches python as one argv item, never through a shell; the python program is fixed text and reads sys.argv[1]

Limits

  • The check runs after the write, so a broken file exists until the next edit fixes it. No edit is ever denied; in deny mode only a commit, a push and a merge stop.
  • The deny mode has no bypass. When a finding cannot be fixed, you turn the gate off with /config-parse mode note.
  • A git command run through a wrapper, an alias or a script that the mod cannot read as git commit|push|merge passes the gate.
  • 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.
  • The index is read before the command runs. A commit whose files change between that read and the run is measured against what the index held at the read.
  • A .env line is checked for its shape only. A wrong value, a missing quote or a duplicate key is not a finding.
  • A JSON file with comments under a name the mod does not know as JSON with comments (a .json name outside the list in item 1) is reported as broken, because that name is read strictly.
  • YAML and TOML need python3; on a machine without it those files are never checked.
  • An edit made outside Edit and Write, for example by a Bash sed, is not seen.

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 264 lines
1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { denyText, doneLines, doneLog, envError, goneLines, goneLog, isCommit, isGuarded, isJsonc, isMissingTool, isNarrowable, jsoncText, jsonError, kindOf, logText, modeOf, noteText, openNote, pythonCode, pythonError, sectionKey, shownPath, sidebarLines, type Kind, type Line, type Mode } from './parse.ts'
3
4const ENABLED_KEY = 'enabled'
5const MODE_KEY = 'mode'
6
7const CONSUMER = 'config-parse'
8
9const USAGE = 'expects nothing (the status), on, off or mode note | deny'
10
11/**
12 * The on/off setting, the mode, the files whose finding still stands (by the path shown, each with the
13 * path on disk and its kind), whether the model is owed a note for them, the kinds this machine cannot
14 * parse, and the root read once at the session's start (`shownRootOf`). A path is shown against that root,
15 * not against `$.session.cwd()`, because a Bash `cd` moves the session's directory.
16 */
17type State = { enabled: boolean; mode: Mode; open: Map<string, { path: string; kind: Kind }>; owed: boolean; skipped: Set<Kind>; reported: boolean; root?: string }
18
19/**
20 * The git repository the session started in, so a file in a sibling directory of a session opened in a
21 * subdirectory still reads short; the session's own directory where git does not answer.
22 */
23async function shownRootOf($: EngineInterface, cwd: string): Promise<string> {
24  try {
25    const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
26    const root = top.stdout.trim()
27    return top.exitCode === 0 && root !== '' ? root : cwd
28  } catch {
29    // No git here, or the command did not run: paths are shown against the session's directory.
30    return cwd
31  }
32}
33
34/**
35 * Reads the on/off setting and the mode from the store, which every window shares, so a change made in
36 * another window applies here at the next hook that acts on it.
37 */
38async function readSettings($: EngineInterface, state: State): Promise<void> {
39  state.enabled = (await $.store.get(ENABLED_KEY)) !== false
40  state.mode = modeOf(String(await $.store.get(MODE_KEY))) ?? 'note'
41}
42
43function errorText(err: unknown): string {
44  return err instanceof Error ? err.message : String(err)
45}
46
47/** The file's text after the edit, or undefined when it cannot be read; the first failure is logged. */
48async function fileText($: EngineInterface, state: State, path: string): Promise<string | undefined> {
49  try {
50    return await $.fs.read(path)
51  } catch (err) {
52    if (!state.reported) $.ui.log(`the edited file was not read: ${errorText(err)}`)
53    state.reported = true
54    return undefined
55  }
56}
57
58/** The parse error python found, or undefined when the file parses or this machine cannot parse the kind. */
59async function pythonCheck($: EngineInterface, state: State, kind: 'yaml' | 'toml', path: string): Promise<string | undefined> {
60  if (state.skipped.has(kind)) return undefined
61  const r = await $.process.run(['python3', '-c', pythonCode(kind), path], { timeoutMs: 10_000 })
62  if (r.exitCode === 0) return undefined
63  if (!isMissingTool(r.stderr)) return pythonError(r.stderr)
64  state.skipped.add(kind)
65  $.ui.log(`${kind.toUpperCase()} files are not checked on this machine: ${pythonError(r.stderr)}`)
66  return undefined
67}
68
69/** Whether the file is no longer on disk; a path that could not be measured is not gone. */
70async function isGone($: EngineInterface, path: string): Promise<boolean> {
71  try {
72    return !(await $.fs.exists(path))
73  } catch {
74    // The path was not measured: the finding is left as it stands.
75    return false
76  }
77}
78
79/** A file that could not be read: it proves nothing, so an open finding on it stays open. */
80const UNREAD = 'unread'
81
82/** The parse error of one file, undefined when it parses, or `UNREAD` when its text could not be read. */
83async function checkFile($: EngineInterface, state: State, kind: Kind, path: string): Promise<string | undefined> {
84  if (kind === 'yaml' || kind === 'toml') return pythonCheck($, state, kind, path)
85  const text = await fileText($, state, path)
86  if (text === undefined) return UNREAD
87  if (kind === 'env') return envError(text)
88  return jsonError(isJsonc(path) ? jsoncText(text) : text)
89}
90
91/**
92 * The finding the person reads: an entry in the shared sidebar's stream while it is open, else the
93 * transcript line, as before. The model's note is another channel and does not change here.
94 */
95async function toPerson($: EngineInterface, shown: string, title: string, lines: Line[], line: string): Promise<void> {
96  try {
97    const taken = await $.sidebar.set({ consumer: CONSUMER, key: sectionKey(shown), title, lines, until: 'stream' })
98    if (taken) return
99  } catch {
100    // The sidebar mod is not installed.
101  }
102  $.ui.log(line)
103}
104
105/** Says, once, why the finding closed (the file parses, or it is gone), and drops it. */
106async function closeOne($: EngineInterface, state: State, kind: Kind, shown: string, gone = false): Promise<void> {
107  state.open.delete(shown)
108  try {
109    await $.sidebar.clear({ consumer: CONSUMER, key: sectionKey(shown) })
110  } catch {
111    // The sidebar mod is not installed.
112  }
113  if (gone) await toPerson($, shown, 'config is gone', goneLines(shown), goneLog(shown))
114  else await toPerson($, shown, 'config parses again', doneLines(shown), doneLog(kind, shown))
115}
116
117/** Checks the file an edit touched, and adds the note when it no longer parses. */
118async function afterEdit($: EngineInterface, state: State, path: string, r: ToolCallResult): Promise<ToolCallResult> {
119  if (r.deny !== undefined || r.isError === true) return r
120  const kind = kindOf(path)
121  if (kind === undefined) return r
122  await readSettings($, state)
123  if (!state.enabled) return r
124  const shown = shownPath(path, state.root ?? (await $.session.cwd()))
125  const error = await checkFile($, state, kind, path)
126  if (error === UNREAD) return r
127  if (error === undefined) {
128    if (state.open.has(shown)) await closeOne($, state, kind, shown)
129    return r
130  }
131  state.open.set(shown, { path, kind })
132  // The note goes to the model, the line to the person: neither reads the other's channel.
133  await toPerson($, shown, 'config does not parse', sidebarLines(shown, error), logText(kind, shown, error))
134  return { ...r, context: [...(r.context ?? []), noteText(kind, shown, error)] }
135}
136
137/**
138 * Parses every open file again and closes the ones an edit fixed or a delete removed, so the gate never
139 * holds a stale finding. A file that is there and cannot be read keeps its finding.
140 */
141async function recheckOpen($: EngineInterface, state: State): Promise<void> {
142  for (const [shown, { path, kind }] of [...state.open]) {
143    if (await isGone($, path)) {
144      await closeOne($, state, kind, shown, true)
145      continue
146    }
147    const error = await checkFile($, state, kind, path)
148    if (error === undefined) await closeOne($, state, kind, shown)
149  }
150}
151
152/**
153 * The files this commit holds, by absolute path, or undefined when git did not answer. Read before the
154 * command runs, so it is the index as the commit will take it.
155 */
156async function stagedPaths($: EngineInterface, state: State): Promise<Set<string> | undefined> {
157  try {
158    const cwd = state.root ?? (await $.session.cwd())
159    const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
160    const staged = await $.process.run(['git', 'diff', '--cached', '--name-only', '-z'], { cwd })
161    if (top.exitCode !== 0 || staged.exitCode !== 0) return undefined
162    const base = top.stdout.trim()
163    return new Set(staged.stdout.split('\0').filter(Boolean).map(p => `${base}/${p}`))
164  } catch {
165    // No git here, or the command did not run: the findings are not narrowed.
166    return undefined
167  }
168}
169
170/**
171 * The findings this command answers for. A `git commit` answers for its own files alone, so a finding of
172 * a file the commit does not hold lets it run. A `push` or a `merge` holds no index to read, so every
173 * finding stands there.
174 */
175async function scopeOf($: EngineInterface, state: State, command: string): Promise<string[]> {
176  const all = [...state.open]
177  if (!isCommit(command) || !isNarrowable(command)) return all.map(([shown]) => shown)
178  const staged = await stagedPaths($, state)
179  if (staged === undefined) return all.map(([shown]) => shown)
180  return all.filter(([, open]) => staged.has(open.path)).map(([shown]) => shown)
181}
182
183async function setMode($: EngineInterface, state: State, arg: string): Promise<string> {
184  const mode = modeOf(arg)
185  if (mode === undefined) return 'mode expects note or deny'
186  await $.store.set(MODE_KEY, mode)
187  state.mode = mode
188  return mode === 'deny'
189    ? 'mode deny: git commit, push and merge stop while a file does not parse'
190    : 'mode note: nothing is stopped, the finding reaches the model as a note'
191}
192
193async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
194  const [first = '', second = ''] = args.trim().split(/\s+/)
195  if (first === 'mode') return setMode($, state, second)
196  const word = args.trim()
197  if (word === 'on' || word === 'off') {
198    await $.store.set(ENABLED_KEY, word === 'on')
199    state.enabled = word === 'on'
200    return word === 'on' ? 'on: each edited JSON, YAML, TOML and .env file is parsed' : 'off: edited files are not parsed'
201  }
202  if (word !== '') return USAGE
203  await readSettings($, state)
204  const open = state.open.size === 0 ? 'no file is open' : `${[...state.open.keys()].join(' · ')} does not parse`
205  return `${state.enabled ? 'on' : 'off'} · mode ${state.mode} · ${open}`
206}
207
208export const register: Register = on => {
209  const state: State = { enabled: true, mode: 'note', open: new Map(), owed: false, skipped: new Set(), reported: false }
210
211  on('session.start', async ($, e, next) => {
212    const r = await next(e)
213    await $.command.register({ name: 'config-parse', description: 'JSON, YAML, TOML and .env files an edit broke: status, on, off, mode note | deny (config-parse)', argumentHint: '[on | off | mode note | mode deny]' })
214    await readSettings($, state)
215    state.root = await shownRootOf($, await $.session.cwd())
216    return r
217  })
218
219  // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
220  on('command.run', { command: 'config-parse' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
221
222  on('tool.call', { tool: 'Edit' }, async ($, e, next) => afterEdit($, state, e.file_path, await next(e)))
223  on('tool.call', { tool: 'Write' }, async ($, e, next) => afterEdit($, state, e.file_path, await next(e)))
224
225  /*
226   * The turn's end parses every open file again and owes the model a note for what is left, because a
227   * finding it did not close would otherwise stand in the pane and reach it never again.
228   */
229  on('turn.complete', async ($, e, next) => {
230    const r = await next(e)
231    if (e.agentId !== undefined) return r
232    await readSettings($, state)
233    if (!state.enabled) return r
234    await recheckOpen($, state)
235    state.owed = state.open.size > 0
236    return r
237  })
238
239  // The note goes to the model alone; the person reads the pane, which carries the same finding.
240  on('prompt.submit', async (_, e, next) => {
241    if (!state.owed || state.open.size === 0) return next(e)
242    state.owed = false
243    return next({ ...e, context: [...(e.context ?? []), openNote([...state.open.keys()])] })
244  })
245
246  /*
247   * The gate: in deny mode a commit, push or merge waits until every open file parses again. A commit
248   * answers for its own files alone, so an unrelated file's finding does not stop it.
249   */
250  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
251    if (state.open.size === 0 || !isGuarded(e.command)) return next(e)
252    await readSettings($, state)
253    if (!state.enabled || state.mode !== 'deny') return next(e)
254    await recheckOpen($, state)
255    if (state.open.size === 0) return next(e)
256    const scoped = await scopeOf($, state, e.command)
257    if (scoped.length === 0) {
258      $.ui.log(`${state.open.size} file(s) still do not parse, and this command holds none of them`)
259      return next(e)
260    }
261    return { deny: denyText(scoped) }
262  })
263}
264
hooks/parse.ts 228 lines
1/** Which files are configuration, how each one is parsed, and the two texts a finding is written as. */
2
3export type Kind = 'json' | 'yaml' | 'toml' | 'env'
4
5/** The kind of a path, or undefined when the file is not configuration this mod reads. */
6export function kindOf(path: string): Kind | undefined {
7  const name = path.split('/').at(-1) ?? ''
8  if (/\.jsonc?$/i.test(name)) return 'json'
9  if (/\.ya?ml$/i.test(name)) return 'yaml'
10  if (/\.toml$/i.test(name)) return 'toml'
11  return /^\.env(\.[\w.-]+)?$/.test(name) ? 'env' : undefined
12}
13
14/** The parse error of a JSON text, or undefined when it parses. */
15export function jsonError(text: string): string | undefined {
16  try {
17    JSON.parse(text)
18    return undefined
19  } catch (err) {
20    return err instanceof Error ? err.message : String(err)
21  }
22}
23
24/**
25 * Whether a JSON file is read by its tool as JSON with comments: a `.jsonc` file, a TypeScript or
26 * JavaScript project file, a VS Code setting and a dev container file, where `//`, `/* *\/` and a trailing
27 * comma are valid.
28 */
29export function isJsonc(path: string): boolean {
30  const name = path.split('/').at(-1) ?? ''
31  return /\.jsonc$/i.test(name)
32    || /^[tj]sconfig(\..+)?\.json$/i.test(name)
33    || /^\.?devcontainer\.json$/i.test(name)
34    || /(^|\/)\.vscode\/[^/]+\.json$/i.test(path)
35}
36
37/** A JSON string, kept as it is, or a comment, blanked; one pass, so `//` inside a string stays text. */
38const STRING_OR_COMMENT = /("(?:\\.|[^"\\])*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g
39
40/** A JSON string, kept as it is, or a comma before a closing bracket, blanked. */
41const STRING_OR_TRAILING_COMMA = /("(?:\\.|[^"\\])*")|,(?=\s*[}\]])/g
42
43/** Every character but a line break as a space, so a parse error still names the right line and column. */
44const blank = (text: string): string => text.replace(/[^\n]/g, ' ')
45
46/** A JSON-with-comments text as plain JSON: comments and trailing commas blanked, every position kept. */
47export function jsoncText(text: string): string {
48  const keepString = (m: string, str: string | undefined): string => str ?? blank(m)
49  return text.replace(STRING_OR_COMMENT, keepString).replace(STRING_OR_TRAILING_COMMA, keepString)
50}
51
52/** A line of a .env file that is not a comment, an empty line or `KEY=value`. */
53const ENV_LINE = /^\s*(export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=/
54
55/** The first line of a .env text that is not a setting, or undefined when every line is one. */
56export function envError(text: string): string | undefined {
57  const lines = text.split('\n')
58  for (const [i, line] of lines.entries()) {
59    const trimmed = line.trim()
60    if (trimmed === '' || trimmed.startsWith('#') || ENV_LINE.test(line)) continue
61    return `line ${i + 1} is not a setting: ${trimmed.slice(0, 60)}`
62  }
63  return undefined
64}
65
66/**
67 * The YAML program: every document of a `---` stream is read, and an application tag (`!Ref`, `!GetAtt`,
68 * `!vault`) builds nothing instead of failing, because both are valid YAML that `safe_load` refuses.
69 */
70const YAML_CODE = [
71  'import sys,yaml',
72  'class L(yaml.SafeLoader):pass',
73  'L.add_multi_constructor("",lambda l,s,n:None)',
74  'for _ in yaml.load_all(open(sys.argv[1],"rb"),L):pass',
75].join('\n')
76
77/** The python program that parses one file of this kind; it prints nothing and fails on a bad file. */
78export function pythonCode(kind: 'yaml' | 'toml'): string {
79  return kind === 'yaml' ? YAML_CODE : 'import sys,tomllib;tomllib.load(open(sys.argv[1],"rb"))'
80}
81
82/** Whether the failure is a missing python or a missing module, so the kind is skipped instead of reported. */
83export function isMissingTool(stderr: string): boolean {
84  return /ModuleNotFoundError|No module named|command not found|ImportError/.test(stderr)
85}
86
87/** The line a python exception starts at, `yaml.scanner.ScannerError: ...`, not a `raise` line of the traceback. */
88const EXCEPTION_LINE = /^[\w.]*(Error|Exception):\s/
89
90/** A PyYAML mark line, `in "<file>", line 1, column 5`, whose position is kept and whose file name is not. */
91const MARK_LINE = /^in ".*", (line \d+, column \d+)$/
92
93/**
94 * The parse error python printed: the exception and every line after it, each mark folded into the line
95 * before it without the file name python repeats. PyYAML prints the mark last, so the last line alone
96 * would be the position without the error.
97 */
98export function pythonError(stderr: string): string {
99  const lines = stderr.split('\n').map(l => l.trim()).filter(l => l !== '')
100  const start = lines.findLastIndex(l => EXCEPTION_LINE.test(l))
101  // Without an exception line (a message from python itself), the last line is the message.
102  const from = start >= 0 ? start : Math.max(lines.length - 1, 0)
103  const parts: string[] = []
104  for (const line of lines.slice(from)) {
105    const mark = MARK_LINE.exec(line)
106    if (mark !== null && parts.length > 0) parts[parts.length - 1] += ` (${mark[1]})`
107    else parts.push(line)
108  }
109  const text = parts.join('; ') || 'the file was not parsed'
110  return text.replace(/^\w*(Error|Exception):\s*/, '').slice(0, 300)
111}
112
113/** The global flags git takes before the subcommand, so `git -c user.name=x commit` is still a commit. */
114const 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))`
115
116/** A `git commit`, `git push` or `git merge` the model runs, not one it only asks about. */
117const GUARDED = new RegExp(String.raw`(^|[\s;&|(])git(?:${GIT_FLAG})*\s+(commit|push|merge)\b`)
118const ASKING = /\s(--dry-run|--help|-h)(\s|$)/
119
120/** Whether the gate stops this command while a finding is open. */
121export function isGuarded(command: string): boolean {
122  return GUARDED.test(command) && !ASKING.test(command)
123}
124
125/** Whether the command is a `git commit`, the one guarded command whose own files can be measured. */
126export function isCommit(command: string): boolean {
127  return GUARDED.exec(command)?.[2] === 'commit'
128}
129
130/**
131 * Whether the index alone says what this commit holds. A `-a` or `-am` commit stages the tracked files
132 * as it runs, and a pathspec after `--` commits paths the index does not hold, so neither is narrowed.
133 */
134export function isNarrowable(command: string): boolean {
135  const words = command.split(/\s+/)
136  return !words.includes('--') && !words.some(w => w === '--all' || /^-[A-Za-z]*a/.test(w))
137}
138
139/** What the deny says: why the command stopped, and the one setting that turns the gate off. */
140export function denyText(open: readonly string[]): string {
141  const named = open.slice(0, MAX_NAMED)
142  if (open.length > MAX_NAMED) named.push(`${open.length - MAX_NAMED} more`)
143  return `stopped: ${open.length} file(s) do not parse: ${named.join(' · ')}. Fix them and run the command again; there is no way around this gate.`
144}
145
146/**
147 * The note the model reads at the next prompt while a finding stands, so a finding it did not close
148 * reaches it again instead of standing in the pane alone. The person reads the pane and needs no line.
149 */
150export function openNote(open: readonly string[]): string {
151  const named = open.slice(0, MAX_NAMED)
152  if (open.length > MAX_NAMED) named.push(`${open.length - MAX_NAMED} more`)
153  return `config-parse: ${open.length} file(s) still do not parse: ${named.join(' · ')}. Fix them.`
154}
155
156/** At most this many files are named in the deny text, the rest counted. */
157const MAX_NAMED = 8
158
159/** The mode of the mod: a note only, or a note and a gate on git commit, push and merge. */
160export type Mode = 'note' | 'deny'
161
162/** The mode a `/config-parse mode <word>` argument names, or undefined when it is not one. */
163export function modeOf(arg: string): Mode | undefined {
164  return arg === 'note' || arg === 'deny' ? arg : undefined
165}
166
167/** `path` shown relative to the session directory when it is inside it. */
168export function shownPath(path: string, cwd: string): string {
169  const base = `${cwd.replace(/\/+$/, '')}/`
170  return path.startsWith(base) ? path.slice(base.length) : path
171}
172
173export function noteText(kind: Kind, shown: string, error: string): string {
174  return `config-parse: ${shown} does not parse as ${label(kind)} after this edit: ${error}. Fix the file before you go on; a build or a service that reads it fails on this.`
175}
176
177/** The transcript line: the file and the error, without the instruction the model reads. */
178export function logText(kind: Kind, shown: string, error: string): string {
179  return `${shown} does not parse as ${label(kind)}: ${error}`
180}
181
182export function doneLog(kind: Kind, shown: string): string {
183  return `${shown} parses as ${label(kind)} again`
184}
185
186/** A piece of a sidebar line in its own colour. */
187type Part = { text: string; kind: 'error' | 'warn' }
188
189/** A sidebar line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
190export type Line = { text: string; kind: 'error' | 'ok'; parts?: Part[] }
191
192/** Where a parser says the error is: `line 2`, `(line 3, column 5)`, `(at line 1, column 5)`. */
193const POSITION = /\(?(?:at )?line \d+(?:,? column \d+)?\)?/
194
195/** The parse error, its position in yellow inside the red, so where to look stands out. */
196function errorLine(error: string): Line {
197  const text = error.slice(0, 200)
198  const at = POSITION.exec(text)
199  if (at === null) return { text, kind: 'error' }
200  const end = at.index + at[0].length
201  const parts: Part[] = [{ text: text.slice(0, at.index), kind: 'error' }, { text: at[0], kind: 'warn' }, { text: text.slice(end), kind: 'error' }]
202  return { text, kind: 'error', parts: parts.filter(p => p.text !== '') }
203}
204
205/**
206 * The finding's sidebar lines: the file first, because the pane draws the section's title and not its
207 * key, then the parse error.
208 */
209export const sidebarLines = (shown: string, error: string): Line[] => [{ text: shown, kind: 'error' }, errorLine(error)]
210
211export const doneLines = (shown: string): { text: string; kind: 'ok' }[] => [{ text: `${shown} parses again`, kind: 'ok' }]
212
213/** The closing of a file that was deleted: nothing reads it any more, so nothing fails on it. */
214export function goneLog(shown: string): string {
215  return `${shown} is gone, and its parse error with it`
216}
217
218export const goneLines = (shown: string): { text: string; kind: 'ok' }[] => [{ text: goneLog(shown), kind: 'ok' }]
219
220function label(kind: Kind): string {
221  return kind === 'env' ? 'a .env file' : kind.toUpperCase()
222}
223
224/** A sidebar section key: the subject cut to what the sidebar takes, so one file keeps one section. */
225export function sectionKey(text: string): string {
226  return text.replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64) || 'note'
227}
228