SLOPSHOPPER

commit-size-guard

Asks before a git commit or git add includes large files, big binaries or build/release artifacts (dmg, zip, app, ipa, xcarchive, DerivedData, node_modules…

newguardcommandstatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · commit-size-guard
› 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 › /commit-size-guard ⎿ commit-size-guard: commit-size-guard is ON. Asks above 5 MB, for binaries above 1 MB and for build/release artifacts. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

commit-size-guard

Asks before a git commit or git add sweeps into git things that should not be there: large files, big binaries, build and release artifacts.

What it does

  • Checks each Bash call through the permission system (tool.check). A git commit or git add that would include an offending file becomes an "ask" prompt; it never denies, and a command already denied stays denied.
  • Finds git through rtk, env, sudo, VAR=value prefixes, a cd <dir> && before it and git -C <dir> / -c key=value.
  • For git commit: lists the staged files with git diff --cached --numstat -z, and for -a/--all (also inside clusters such as -am) the modified tracked files with git diff --numstat -z. Deletions are left out. Sizes come from the working tree (stat), or from the staged blob (git cat-file -s) when the file is no longer there.
  • For git add: the paths named on the line (even ignored ones, for git add -f), the untracked files the pathspec covers (git ls-files --others --exclude-standard -z; the whole repository for -A without a path) and the modified tracked files under it. -n/--dry-run is not checked.
  • Asks when a file:
  • is larger than maxMB (default 5 MB);
  • is binary (numstat shows - -) and larger than 1 MB;
  • is a build or release artifact: *.dmg, *.zip, *.ipa, anything inside a *.app/ or *.xcarchive bundle, DerivedData/, node_modules/ or .build/, or any file under the repository's top-level release/ folder except *.md release notes.
  • The prompt lists up to 5 offenders with their sizes (an artifact folder counts once, with its number of files) and suggests .gitignore and git restore --staged.
  • Reads only machine formats (-z, numstat, cat-file -s), so a localised git or RTK's output rewriting do not change the result.
  • Each git call has a 5 second timeout; when git fails, times out or is not a repository, the engine's own decision stands.

Commands

CommandEffect
/commit-size-guard statusShows whether the guard is on and the limits.
/commit-size-guard offStops asking, kept across sessions; status line shows commit-size-guard: OFF.
/commit-size-guard onTurns it back on.

Settings

OptionDefaultMeaning
maxMB5A file larger than this many MB asks first.

Install

claude --plugin-dir /path/to/ModsTools/mods/commit-size-guard

Limits

  • git commit <path> / --only / --include commit working-tree content of the named paths: only what is staged (and -a) is checked.
  • Reads the command line word by word: it does not see through $(...), aliases, scripts or bash -c with complex quoting.
  • Sizes come from the working tree first: a partially staged file is sized as it stands on disk.
  • A git listing over 4 MiB (what one process run keeps) is checked up to its last whole entry, and the prompt says the list was cut off.
  • At most 500 files are sized per check (an un-ignored node_modules can hold thousands); beyond that only the path patterns apply.
  • The prompt goes through the permission system: in auto/bypassPermissions mode the mode settles the question.

Develop

claude plugin validate mods/commit-size-guard
claude plugin test mods/commit-size-guard   # 24 tests
Source 2 files
hooks/register.ts 179 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4  MB,
5  addArgs,
6  artifactOf,
7  askReason,
8  commitArgs,
9  gitRuns,
10  offenders,
11  parseMaxMb,
12  parseNulList,
13  parseNumstat,
14  parseSize,
15  relativeTo,
16  resolvePath,
17  wholeEntries,
18  withTruncation,
19} from './rules'
20import type { Candidate, Changed, GitRun, Offender } from './rules'
21
22const GIT_MS = 5_000
23const OFF = 'off'
24// Untracked files sized one by one past this many are left unsized (an un-ignored node_modules can hold thousands).
25const MAX_STATS = 500
26const MAX_CAT_FILE = 5
27
28class GitFailed extends Error {}
29
30// Set when a git listing went past what one process run keeps (4 MiB of stdout).
31type Seen = { truncated: boolean }
32
33// One git call; any failure (cannot start, timeout, non-zero exit) throws so the engine's verdict stands.
34// A cut-off `-z` listing is kept up to its last whole entry and noted in `seen`.
35async function git($: EngineInterface, dir: string, args: string[], seen?: Seen): Promise<string> {
36  let ran
37  try {
38    ran = await $.process.run(['git', '-C', dir, ...args], { timeoutMs: GIT_MS })
39  } catch {
40    throw new GitFailed('git unavailable')
41  }
42  if (ran.exitCode !== 0) throw new GitFailed(`git ${args[0]} failed`)
43  if (!ran.isStdoutTruncated) return ran.stdout
44  if (seen === undefined) throw new GitFailed(`git ${args[0]} output cut off`)
45  seen.truncated = true
46
47  return wholeEntries(ran.stdout)
48}
49
50async function rootOf($: EngineInterface, dir: string): Promise<string> {
51  const top = (await git($, dir, ['rev-parse', '--show-toplevel'])).trim()
52  if (!top.startsWith('/')) throw new GitFailed('no repository root')
53
54  return top
55}
56
57async function worktreeSize($: EngineInterface, root: string, path: string): Promise<number | null> {
58  const stat = await $.fs.stat(`${root}/${path}`).catch(() => null)
59
60  return stat !== null && stat.kind === 'file' ? stat.size : null
61}
62
63// Sizes the files: from the working tree, else the staged blob (`git cat-file -s :path`) for a few.
64async function sized($: EngineInterface, root: string, files: readonly Changed[], useIndex: boolean): Promise<Candidate[]> {
65  const out: Candidate[] = []
66  let stats = 0
67  let catFiles = 0
68  for (const file of files) {
69    let size: number | null = null
70    if (stats < MAX_STATS) {
71      stats += 1
72      size = await worktreeSize($, root, file.path)
73    }
74    if (size === null && useIndex && catFiles < MAX_CAT_FILE) {
75      catFiles += 1
76      size = parseSize(await git($, root, ['cat-file', '-s', `:${file.path}`]).catch(() => ''))
77    }
78    out.push({ path: file.path, size, binary: file.binary })
79  }
80
81  return out
82}
83
84function merge(lists: readonly Changed[][]): Changed[] {
85  const byPath = new Map<string, Changed>()
86  for (const list of lists) for (const one of list) byPath.set(one.path, { path: one.path, binary: one.binary || (byPath.get(one.path)?.binary ?? false) })
87
88  return [...byPath.values()]
89}
90
91const NUMSTAT = ['--numstat', '-z', '--no-renames', '--diff-filter=d']
92
93async function commitOffenders($: EngineInterface, run: GitRun, cwd: string, maxBytes: number): Promise<Offender[]> {
94  const opts = commitArgs(run.args)
95  if (opts.dryRun) return []
96  const dir = resolvePath(run.dir ?? '.', cwd)
97  const root = await rootOf($, dir)
98  const seen: Seen = { truncated: false }
99  const lists = [parseNumstat(await git($, dir, ['diff', '--cached', ...NUMSTAT], seen))]
100  if (opts.all) lists.push(parseNumstat(await git($, dir, ['diff', ...NUMSTAT], seen)))
101
102  return withTruncation(offenders(await sized($, root, merge(lists), true), maxBytes), seen.truncated)
103}
104
105async function addOffenders($: EngineInterface, run: GitRun, cwd: string, maxBytes: number): Promise<Offender[]> {
106  const opts = addArgs(run.args)
107  if (opts.dryRun || (opts.paths.length === 0 && !opts.all && !opts.update)) return []
108  const dir = resolvePath(run.dir ?? '.', cwd)
109  const root = await rootOf($, dir)
110  const specs = opts.paths.length > 0 ? opts.paths : [':/']
111  // Paths named on the line, as given, even when ignored (`git add -f`).
112  const named: Changed[] = []
113  for (const given of opts.paths) {
114    const rel = relativeTo(resolvePath(given, dir), root)
115    if (rel !== null && rel !== '' && !/[*?[]/.test(given) && artifactOf(rel) !== null) named.push({ path: rel, binary: false })
116  }
117  const seen: Seen = { truncated: false }
118  const tracked = parseNumstat(await git($, dir, ['diff', ...NUMSTAT, '--', ...specs], seen))
119  const untracked = opts.update
120    ? []
121    : parseNulList(await git($, dir, ['ls-files', '--others', '--exclude-standard', '--full-name', '-z', '--', ...specs], seen)).map(path => ({ path, binary: false }))
122
123  return withTruncation(offenders(await sized($, root, merge([named, tracked, untracked]), false), maxBytes), seen.truncated)
124}
125
126async function inspect($: EngineInterface, command: string, maxBytes: number): Promise<{ found: Offender[]; verb: 'commit' | 'add' } | null> {
127  const runs = gitRuns(command).filter(run => run.sub === 'commit' || run.sub === 'add')
128  if (runs.length === 0) return null
129  const cwd = await $.session.cwd()
130  for (const run of runs) {
131    const found = run.sub === 'commit' ? await commitOffenders($, run, cwd, maxBytes) : await addOffenders($, run, cwd, maxBytes)
132    if (found.length > 0) return { found, verb: run.sub === 'commit' ? 'commit' : 'add' }
133  }
134
135  return null
136}
137
138const isOff = async ($: EngineInterface) => (await $.store.get(OFF).catch(() => false)) === true
139
140export const register: Register = (on, options) => {
141  const maxMb = parseMaxMb(options?.maxMB)
142
143  on('session.start', async ($, e, next) => {
144    await $.command.register({
145      name: 'commit-size-guard',
146      description: 'commit-size-guard on|off|status: ask before committing large files or build artifacts',
147    })
148    if (await isOff($)) $.ui.status('commit-size-guard: OFF')
149
150    return next(e)
151  })
152
153  on('command.run', { command: 'commit-size-guard' }, async ($, e) => {
154    const arg = e.args.trim()
155    if (arg === 'off' || arg === 'on') {
156      await $.store.set(OFF, arg === 'off')
157      $.ui.status(arg === 'off' ? 'commit-size-guard: OFF' : undefined)
158    }
159    const off = await isOff($)
160
161    return { text: `commit-size-guard is ${off ? 'OFF (kept across sessions)' : 'ON'}. Asks above ${maxMb} MB, for binaries above 1 MB and for build/release artifacts.` }
162  })
163
164  on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
165    const verdict = await next(e)
166    const command = (e.input as { command?: unknown }).command
167    if (verdict.decision === 'deny' || typeof command !== 'string' || !/\bgit\b/.test(command) || (await isOff($))) return verdict
168    let result
169    try {
170      result = await inspect($, command, maxMb * MB)
171    } catch {
172      return verdict
173    }
174    if (result === null) return verdict
175
176    return { decision: 'ask', reason: askReason(result.found, result.verb) }
177  })
178}
179
hooks/rules.ts 334 lines
1export const DEFAULT_MAX_MB = 5
2export const MB = 1024 * 1024
3export const BINARY_MAX_BYTES = MB
4export const MAX_LISTED = 5
5
6// ---------------------------------------------------------------- shell
7
8// The words of each simple command of a shell line, quotes removed (a quoted argument stays one word).
9export function shellWords(command: string): string[][] {
10  const statements: string[][] = []
11  let words: string[] = []
12  let word = ''
13  let inWord = false
14  const endWord = () => {
15    if (inWord) words.push(word)
16    word = ''
17    inWord = false
18  }
19  const endStatement = () => {
20    endWord()
21    if (words.length > 0) statements.push(words)
22    words = []
23  }
24  for (let i = 0; i < command.length; i++) {
25    const c = command[i]!
26    if (c === "'") {
27      const end = command.indexOf("'", i + 1)
28      word += command.slice(i + 1, end === -1 ? undefined : end)
29      inWord = true
30      i = end === -1 ? command.length : end
31    } else if (c === '"') {
32      inWord = true
33      for (i++; i < command.length && command[i] !== '"'; i++) {
34        if (command[i] === '\\' && '"\\$`\n'.includes(command[i + 1] ?? 'x')) i++
35        word += command[i] ?? ''
36      }
37    } else if (c === '\\') {
38      if (command[i + 1] !== '\n') {
39        word += command[i + 1] ?? ''
40        inWord = true
41      }
42      i++
43    } else if (c === ';' || c === '&' || c === '|' || c === '\n' || c === '(' || c === ')') endStatement()
44    else if (c === ' ' || c === '\t') endWord()
45    else {
46      word += c
47      inWord = true
48    }
49  }
50  endStatement()
51
52  return statements
53}
54
55const isAssignment = (word: string) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)
56
57// Prefixes that run the command after them, with their options that take an argument.
58const WRAPPERS: Record<string, Set<string>> = {
59  rtk: new Set(),
60  env: new Set(['-u', '-C', '-S']),
61  command: new Set(),
62  nohup: new Set(),
63  time: new Set(),
64  sudo: new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-U']),
65}
66
67function unwrap(words: string[]): string[] {
68  let i = 0
69  for (;;) {
70    while (words[i] !== undefined && isAssignment(words[i]!)) i++
71    const takesArg = WRAPPERS[words[i] ?? '']
72    if (takesArg === undefined) break
73    i++
74    while (words[i]?.startsWith('-')) i += takesArg.has(words[i]!) ? 2 : 1
75  }
76
77  return words.slice(i)
78}
79
80// `a` then `b`, `b` absolute replacing `a`; undefined stands for the session folder.
81export function joinDir(a: string | undefined, b: string): string {
82  if (b.startsWith('/') || a === undefined) return b
83
84  return `${a.replace(/\/+$/, '')}/${b}`
85}
86
87// An absolute path with `.`, `..` and repeated slashes resolved; relative paths are taken under `cwd`.
88export function resolvePath(path: string, cwd: string): string {
89  const joined = path.startsWith('/') ? path : `${cwd.replace(/\/+$/, '')}/${path}`
90  const parts: string[] = []
91  for (const part of joined.split('/')) {
92    if (part === '' || part === '.') continue
93    if (part === '..') parts.pop()
94    else parts.push(part)
95  }
96
97  return `/${parts.join('/')}`
98}
99
100const GIT_OPTS_WITH_ARG = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--super-prefix', '--exec-path'])
101
102export type GitRun = { sub: string; args: string[]; dir: string | undefined }
103
104// Each `git <sub>` of the line with the folder it runs in (`cd` before it, `git -C`), relative to the session folder.
105export function gitRuns(command: string): GitRun[] {
106  const runs: GitRun[] = []
107  let dir: string | undefined
108  for (const statement of shellWords(command)) {
109    const words = unwrap(statement)
110    if (words[0] === 'cd') {
111      const target = words[1]
112      if (target !== undefined && target !== '-' && !target.startsWith('~') && !target.includes('$')) dir = joinDir(dir, target)
113      continue
114    }
115    if (words[0] !== 'git') continue
116    let runDir = dir
117    let i = 1
118    while (words[i]?.startsWith('-')) {
119      const opt = words[i]!
120      if (opt === '-C' && words[i + 1] !== undefined) runDir = joinDir(runDir, words[i + 1]!)
121      i += GIT_OPTS_WITH_ARG.has(opt) ? 2 : 1
122    }
123    const sub = words[i]
124    if (sub !== undefined) runs.push({ sub, args: words.slice(i + 1), dir: runDir })
125  }
126
127  return runs
128}
129
130// ---------------------------------------------------------------- git commit / add arguments
131
132const COMMIT_SHORT_WITH_ARG = new Set(['m', 'F', 'C', 'c', 't'])
133const COMMIT_LONG_WITH_ARG = new Set([
134  '--message',
135  '--file',
136  '--reuse-message',
137  '--reedit-message',
138  '--template',
139  '--author',
140  '--date',
141  '--cleanup',
142  '--fixup',
143  '--squash',
144  '--trailer',
145  '--pathspec-from-file',
146])
147
148export type CommitArgs = { all: boolean; dryRun: boolean; paths: string[] }
149
150export function commitArgs(args: string[]): CommitArgs {
151  const out: CommitArgs = { all: false, dryRun: false, paths: [] }
152  for (let i = 0; i < args.length; i++) {
153    const arg = args[i]!
154    if (arg === '--') {
155      out.paths.push(...args.slice(i + 1))
156      break
157    }
158    if (arg.startsWith('--')) {
159      const name = arg.split('=')[0]!
160      if (name === '--all') out.all = true
161      else if (name === '--dry-run') out.dryRun = true
162      if (COMMIT_LONG_WITH_ARG.has(name) && !arg.includes('=')) i++
163    } else if (arg.startsWith('-') && arg.length > 1) {
164      for (let j = 1; j < arg.length; j++) {
165        const flag = arg[j]!
166        if (flag === 'a') out.all = true
167        if (COMMIT_SHORT_WITH_ARG.has(flag)) {
168          if (j === arg.length - 1) i++
169          break
170        }
171        // -u and -S take their value attached only
172        if (flag === 'u' || flag === 'S') break
173      }
174    } else out.paths.push(arg)
175  }
176
177  return out
178}
179
180export type AddArgs = { all: boolean; update: boolean; dryRun: boolean; paths: string[] }
181
182export function addArgs(args: string[]): AddArgs {
183  const out: AddArgs = { all: false, update: false, dryRun: false, paths: [] }
184  for (let i = 0; i < args.length; i++) {
185    const arg = args[i]!
186    if (arg === '--') {
187      out.paths.push(...args.slice(i + 1))
188      break
189    }
190    if (arg.startsWith('--')) {
191      const name = arg.split('=')[0]!
192      if (name === '--all' || name === '--no-ignore-removal') out.all = true
193      else if (name === '--update') out.update = true
194      else if (name === '--dry-run') out.dryRun = true
195      else if (name === '--chmod' && !arg.includes('=')) i++
196    } else if (arg.startsWith('-') && arg.length > 1) {
197      if (arg.includes('A')) out.all = true
198      if (arg.includes('u')) out.update = true
199      if (arg.includes('n')) out.dryRun = true
200    } else out.paths.push(arg)
201  }
202
203  return out
204}
205
206// ---------------------------------------------------------------- git output
207
208export type Changed = { path: string; binary: boolean }
209
210// `git diff --numstat -z`: `added\tdeleted\tpath\0`, or `added\tdeleted\t\0from\0to\0` for a rename; binary shows `-\t-`.
211export function parseNumstat(text: string): Changed[] {
212  const fields = text.split('\0')
213  const out: Changed[] = []
214  for (let i = 0; i < fields.length; i++) {
215    const field = fields[i]!
216    const match = /^(-|\d+)\t(-|\d+)\t([\s\S]*)$/.exec(field)
217    if (match === null) continue
218    const binary = match[1] === '-' && match[2] === '-'
219    let path = match[3]!
220    if (path === '') {
221      path = fields[i + 2] ?? ''
222      i += 2
223    }
224    if (path !== '') out.push({ path, binary })
225  }
226
227  return out
228}
229
230// A `-z` listing cut off mid-entry, kept up to its last whole entry.
231export function wholeEntries(text: string): string {
232  return text.slice(0, text.lastIndexOf('\0') + 1)
233}
234
235export const parseNulList = (text: string) => text.split('\0').filter(one => one !== '')
236
237export function parseSize(text: string): number | null {
238  const n = Number(text.trim())
239
240  return text.trim() !== '' && Number.isFinite(n) && n >= 0 ? n : null
241}
242
243// The path relative to `root`, or null when it is outside.
244export function relativeTo(path: string, root: string): string | null {
245  const base = root.replace(/\/+$/, '')
246  if (path === base) return ''
247
248  return path.startsWith(`${base}/`) ? path.slice(base.length + 1) : null
249}
250
251// ---------------------------------------------------------------- what should not be committed
252
253const ARCHIVE = /\.(dmg|zip|ipa)$/i
254const BUNDLE = /\.(app|xcarchive)$/i
255const CACHE_DIRS = new Set(['DerivedData', 'node_modules', '.build'])
256
257// Why a repo-relative path looks like a build or release artifact, with the folder that holds it; null otherwise.
258export function artifactOf(path: string): { reason: string; group: string } | null {
259  const parts = path.split('/').filter(Boolean)
260  for (let i = 0; i < parts.length; i++) {
261    const part = parts[i]!
262    const isDir = i < parts.length - 1
263    if (CACHE_DIRS.has(part)) return { reason: `inside ${part}/`, group: `${parts.slice(0, i + 1).join('/')}/` }
264    if (BUNDLE.test(part) && (isDir || /\.xcarchive$/i.test(part))) {
265      return { reason: `inside a ${part.slice(part.lastIndexOf('.'))} bundle`, group: `${parts.slice(0, i + 1).join('/')}/` }
266    }
267  }
268  const name = parts[parts.length - 1] ?? ''
269  if (ARCHIVE.test(name)) return { reason: `build artifact (*${name.slice(name.lastIndexOf('.')).toLowerCase()})`, group: path }
270  if (parts[0] === 'release' && parts.length > 1 && !/\.md$/i.test(name)) return { reason: 'release artifact (release/)', group: path }
271
272  return null
273}
274
275export type Candidate = { path: string; size: number | null; binary: boolean }
276export type Offender = { path: string; size: number | null; reasons: string[]; files: number }
277
278// The files to ask about, an artifact folder counted once with its files.
279export function offenders(files: readonly Candidate[], maxBytes: number): Offender[] {
280  const out = new Map<string, Offender>()
281  for (const file of files) {
282    const artifact = artifactOf(file.path)
283    const reasons: string[] = []
284    if (artifact !== null) reasons.push(artifact.reason)
285    if (file.size !== null && file.size > maxBytes) reasons.push(`over ${formatMb(maxBytes)}`)
286    else if (file.binary && file.size !== null && file.size > BINARY_MAX_BYTES) reasons.push(`binary over ${formatMb(BINARY_MAX_BYTES)}`)
287    if (reasons.length === 0) continue
288    const key = artifact?.group ?? file.path
289    const seen = out.get(key)
290    if (seen === undefined) out.set(key, { path: key, size: file.size, reasons, files: 1 })
291    else {
292      seen.files += 1
293      seen.size = seen.size === null || file.size === null ? seen.size ?? file.size : seen.size + file.size
294      for (const reason of reasons) if (!seen.reasons.includes(reason)) seen.reasons.push(reason)
295    }
296  }
297
298  return [...out.values()]
299}
300
301export const TRUNCATED: Offender = { path: 'file list', size: null, reasons: ['over 4 MiB of paths, only its start was checked'], files: 1 }
302
303// A listing too long to read in full is itself worth asking about (tens of thousands of files).
304export function withTruncation(found: readonly Offender[], truncated: boolean): Offender[] {
305  return truncated ? [...found, TRUNCATED] : [...found]
306}
307
308export function formatMb(bytes: number): string {
309  const mb = bytes / MB
310  if (mb >= 1) return `${Number.isInteger(mb) ? mb : mb.toFixed(1)} MB`
311
312  return `${(bytes / 1024).toFixed(1)} KB`
313}
314
315export function askReason(found: readonly Offender[], verb: 'commit' | 'add'): string {
316  const lines = found.slice(0, MAX_LISTED).map(one => {
317    const size = one.size === null ? '' : `, ${formatMb(one.size)}`
318    const count = one.files > 1 ? ` (${one.files} files)` : ''
319    return `- ${one.path}${count}: ${one.reasons.join(', ')}${size}`
320  })
321  if (found.length > MAX_LISTED) lines.push(`- and ${found.length - MAX_LISTED} more`)
322  const what = verb === 'commit' ? 'this commit includes' : 'this git add stages'
323  const fix =
324    verb === 'commit'
325      ? 'add them to .gitignore and unstage them (git restore --staged <path>)'
326      : 'add them to .gitignore instead of staging them'
327
328  return `commit-size-guard: ${what} files that usually do not belong in git:\n${lines.join('\n')}\nConfirm only if they are meant to be versioned; otherwise ${fix}. Stop the checks with /commit-size-guard off.`
329}
330
331export function parseMaxMb(raw: unknown): number {
332  return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MAX_MB
333}
334