image-guard 5.2.2 → 5.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ Starting with version 5.2.0, all notable changes to Image Guard are documented i
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [5.2.3] - 2026-02-21
8
+
9
+ ### Fixed
10
+
11
+ * Improved corrupt-file error detection to cover WebP and AVIF formats, normalize message casing before matching, and avoid false positives from the overly broad `Invalid` string match
12
+ * Warned when a `.bak` file is left behind after a failed file replacement
13
+ * Removed a dead code branch in result processing
14
+
15
+ ### Changed
16
+
17
+ * Moved to compression and conversion running in parallel (non-overlapping file sets), reducing wall-clock time when `--heic-to-avif` is active
18
+ * Reduced directory traversals from two to one in non-staged mode when `--heic-to-avif` is active
19
+ * Extracted shared `MAX_FILE_SIZE` constant; aligned `node:fs` and `node:path` import style in `utils.js`
20
+ * Extended test coverage for corrupt file detection and reporting
21
+
7
22
  ## [5.2.2] - 2026-02-17
8
23
 
9
24
  ### Fixed
@@ -14,7 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
14
29
 
15
30
  ### Changed
16
31
 
17
- * HEIC-to-AVIF conversion now uses lossy encoding (quality 80) instead of lossless, producing files smaller than the HEIC originals
32
+ * Adjusted HEIC-to-AVIF conversion to use lossy instead of lossless encoding (quality 80), to produce files smaller than the HEIC originals but still at high quality
18
33
  * Removed redundant lossless compression pass after HEIC conversion
19
34
  * Documented Display P3 to sRGB color space mapping during HEIC conversion
20
35
 
@@ -141,7 +141,7 @@ describe('Image Guard', () => {
141
141
  assert.strictEqual(allCompressed, true)
142
142
  })
143
143
 
144
- test('Do not modify files in dry run', () => {
144
+ test('Ensure files are not modified in dry run', () => {
145
145
  const originalStats = fs.readdirSync(testFolderGit).sort().map(file => {
146
146
  const filePath = path.join(testFolderGit, file)
147
147
  return { file, stats: fs.statSync(filePath) }
@@ -298,7 +298,7 @@ describe('Image Guard', () => {
298
298
  fs.rmSync(tempDir, { recursive: true, force: true })
299
299
  })
300
300
 
301
- test('Quiet mode suppresses per-file logs but keeps summary', () => {
301
+ test('Ensure quiet mode suppresses per-file logs but keeps summary', () => {
302
302
  // Prepare isolated temp directory with test images
303
303
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-quiet-'))
304
304
  const tempTestFolder = path.join(tempDir, 'test')
@@ -320,7 +320,7 @@ describe('Image Guard', () => {
320
320
  assert.strictEqual(!(/Compressed|Skipped/.test(stdout)), true)
321
321
  })
322
322
 
323
- test('Dry and quiet runs leave no artifacts and do not mutate files', () => {
323
+ test('Ensure dry and quiet runs leave no artifacts and do not mutate files', () => {
324
324
  // Use isolated temp directory
325
325
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-dry-quiet-'))
326
326
  const tempTestFolder = path.join(tempDir, 'test')
@@ -368,7 +368,7 @@ describe('Image Guard', () => {
368
368
  fs.rmSync(tempDir, { recursive: true, force: true })
369
369
  })
370
370
 
371
- test('No .bak files remain after normal compression', () => {
371
+ test('Ensure no .bak files remain after normal compression', () => {
372
372
  // Prepare isolated temp directory with test images
373
373
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-bak-'))
374
374
  const tempTestFolder = path.join(tempDir, 'test')
@@ -428,7 +428,7 @@ describe('Image Guard', () => {
428
428
  fs.rmSync(tempDir, { recursive: true, force: true })
429
429
  })
430
430
 
431
- test('`--keep-heic` preserves original HEIC file', () => {
431
+ test('Ensure `--keep-heic` preserves original HEIC file', () => {
432
432
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-keep-heic-'))
433
433
  const tempTestFolder = path.join(tempDir, 'test')
434
434
  copyFiles(testFolder, tempTestFolder)
@@ -451,7 +451,7 @@ describe('Image Guard', () => {
451
451
  fs.rmSync(tempDir, { recursive: true, force: true })
452
452
  })
453
453
 
454
- test('Dry run does not convert HEIC files but reports sizes', () => {
454
+ test('Ensure dry run does not convert HEIC files but reports sizes', () => {
455
455
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-heic-dry-'))
456
456
  const tempTestFolder = path.join(tempDir, 'test')
457
457
  copyFiles(testFolder, tempTestFolder)
@@ -490,7 +490,7 @@ describe('Image Guard', () => {
490
490
  fs.rmSync(tempDir, { recursive: true, force: true })
491
491
  })
492
492
 
493
- test('HEIC files are ignored without `--heic-to-avif` flag', () => {
493
+ test('Ensure HEIC files are ignored without `--heic-to-avif` flag', () => {
494
494
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-heic-noflag-'))
495
495
  const tempTestFolder = path.join(tempDir, 'test')
496
496
  copyFiles(testFolder, tempTestFolder)
@@ -514,7 +514,25 @@ describe('Image Guard', () => {
514
514
  fs.rmSync(tempDir, { recursive: true, force: true })
515
515
  })
516
516
 
517
- test('`--keep-heic` without `--heic-to-avif` warns', () => {
517
+ test('Skip and report corrupt files', () => {
518
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-corrupt-'))
519
+ const tempTestFolder = path.join(tempDir, 'test')
520
+ copyFiles(testFolder, tempTestFolder)
521
+
522
+ const originalCwd = process.cwd()
523
+ let output = ''
524
+ try {
525
+ process.chdir(tempDir)
526
+ output = execSync(`node "${imageGuardScript}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
527
+ } finally {
528
+ process.chdir(originalCwd)
529
+ fs.rmSync(tempDir, { recursive: true, force: true })
530
+ }
531
+
532
+ assert.match(output, /Skipped.*test#corrupt\.gif.*corrupt file/i)
533
+ })
534
+
535
+ test('Ensure `--keep-heic` without `--heic-to-avif` issues warning', () => {
518
536
  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-guard-keep-warn-'))
519
537
  const tempTestFolder = path.join(tempDir, 'test')
520
538
  copyFiles(testFolder, tempTestFolder)
package/package.json CHANGED
@@ -46,5 +46,5 @@
46
46
  "test": "node --test bin/image-guard.test.js"
47
47
  },
48
48
  "type": "module",
49
- "version": "5.2.2"
49
+ "version": "5.2.3"
50
50
  }
package/src/index.js CHANGED
@@ -97,11 +97,7 @@ export async function runImageGuard() {
97
97
  let addedKB = 0
98
98
  for (const r of results) {
99
99
  if (r.status === 'fulfilled') {
100
- if (typeof r.value === 'number') {
101
- addedKB += r.value
102
- } else {
103
- hadFailures = true
104
- }
100
+ addedKB += r.value
105
101
  } else {
106
102
  hadFailures = true
107
103
  const reason = r.reason && r.reason.message ? r.reason.message : String(r.reason)
@@ -192,32 +188,44 @@ export async function runImageGuard() {
192
188
  const diffOutput = await git.raw(['diff', '--name-only', '--cached', '--diff-filter=ACMRT'])
193
189
  const stagedFiles = diffOutput.split('\n').map(s => s.trim()).filter(Boolean)
194
190
 
195
- const compressionFiles = await filterStagedFiles(stagedFiles, fileTypes)
196
- totalFiles += compressionFiles.length
197
- hadFailures = await compress(compressionFiles, argv.dry)
198
-
199
- if (doConversion) {
200
- const conversionFiles = await filterStagedFiles(stagedFiles, convertTypes)
201
- totalFiles += conversionFiles.length
202
- const convFailed = await convert(conversionFiles, argv.dry, argv['keep-heic'])
203
- hadFailures = hadFailures || convFailed
204
- }
191
+ // Collect compression and conversion candidates in parallel
192
+ const [compressionFiles, conversionFiles] = await Promise.all([
193
+ filterStagedFiles(stagedFiles, fileTypes),
194
+ doConversion ? filterStagedFiles(stagedFiles, convertTypes) : Promise.resolve([])
195
+ ])
196
+ totalFiles += compressionFiles.length + conversionFiles.length
197
+
198
+ // Run compression and conversion in parallel (non-overlapping file sets)
199
+ const [compFailed, convFailed] = await Promise.all([
200
+ compress(compressionFiles, argv.dry),
201
+ convert(conversionFiles, argv.dry, argv['keep-heic'])
202
+ ])
203
+ hadFailures = compFailed || convFailed
205
204
  } catch (err) {
206
205
  console.error(err)
207
206
  hadFailures = true
208
207
  }
209
208
  } else {
210
- const patterns = getFilePattern(argv.ignore)
211
- const compressionFiles = await findFiles(patterns)
209
+ // Single directory traversal for all relevant types
210
+ const allFiles = await findFiles(getFilePattern(argv.ignore, allTypes))
211
+
212
+ const compExts = new Set(fileTypes)
213
+ const compressionFiles = allFiles.filter(f => compExts.has(path.extname(f).slice(1).toLowerCase()))
212
214
  totalFiles += compressionFiles.length
213
- hadFailures = await compress(compressionFiles, argv.dry)
214
215
 
215
216
  if (doConversion) {
216
- const convPatterns = getFilePattern(argv.ignore, convertTypes)
217
- const conversionFiles = await findFiles(convPatterns)
217
+ const convExts = new Set(convertTypes)
218
+ const conversionFiles = allFiles.filter(f => convExts.has(path.extname(f).slice(1).toLowerCase()))
218
219
  totalFiles += conversionFiles.length
219
- const convFailed = await convert(conversionFiles, argv.dry, argv['keep-heic'])
220
- hadFailures = hadFailures || convFailed
220
+
221
+ // Run compression and conversion in parallel (non-overlapping file sets)
222
+ const [compFailed, convFailed] = await Promise.all([
223
+ compress(compressionFiles, argv.dry),
224
+ convert(conversionFiles, argv.dry, argv['keep-heic'])
225
+ ])
226
+ hadFailures = compFailed || convFailed
227
+ } else {
228
+ hadFailures = await compress(compressionFiles, argv.dry)
221
229
  }
222
230
  }
223
231
 
package/src/utils.js CHANGED
@@ -1,11 +1,13 @@
1
1
  // This file, which had been forked from imagemin-merlin, was modified for image-guard: https://github.com/sumcumo/imagemin-merlin/compare/master...j9t:master
2
2
 
3
- import fs from 'fs'
4
- import path from 'path'
3
+ import { promises as fs } from 'node:fs'
4
+ import path from 'node:path'
5
5
  import sharp from 'sharp'
6
6
  import { styleText } from 'node:util'
7
7
  import decode from 'heic-decode'
8
8
 
9
+ const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100 MB
10
+
9
11
  const logMessage = (message, dry, color = 'yellow', quiet = false) => {
10
12
  if (quiet) return
11
13
  const prefix = dry ? 'Dry run: ' : ''
@@ -38,9 +40,7 @@ const compression = async (filename, dry, quiet = false) => {
38
40
  return 0
39
41
  }
40
42
 
41
- const maxFileSize = 100 * 1024 * 1024 // 100 MB
42
-
43
- if (fileSizeBefore > maxFileSize) {
43
+ if (fileSizeBefore > MAX_FILE_SIZE) {
44
44
  logMessage(`Skipped ${filename} (file too large: ${sizeReadable(fileSizeBefore)})`, dry, 'yellow', quiet)
45
45
  return 0
46
46
  }
@@ -114,17 +114,17 @@ const compression = async (filename, dry, quiet = false) => {
114
114
  details = `${sizeReadable(fileSizeBefore)} → ${sizeReadable(fileSizeAfter)}`
115
115
  if (!dry) {
116
116
  // Only now create a backup and replace the original
117
- await retryFileOperation(() => fs.promises.copyFile(filename, filenameBackup))
117
+ await retryFileOperation(() => fs.copyFile(filename, filenameBackup))
118
118
  // Prefer atomic rename when possible
119
119
  try {
120
- await retryFileOperation(() => fs.promises.rename(tempFilePath, filename))
120
+ await retryFileOperation(() => fs.rename(tempFilePath, filename))
121
121
  // Temp file was renamed (consumed)
122
122
  tempConsumed = true
123
123
  replacementSucceeded = true
124
124
  } catch {
125
125
  // Fallback to copy when rename across devices isn’t possible
126
- await retryFileOperation(() => fs.promises.copyFile(tempFilePath, filename))
127
- await retryFileOperation(() => fs.promises.unlink(tempFilePath))
126
+ await retryFileOperation(() => fs.copyFile(tempFilePath, filename))
127
+ await retryFileOperation(() => fs.unlink(tempFilePath))
128
128
  // Temp file explicitly removed after copy
129
129
  tempConsumed = true
130
130
  replacementSucceeded = true
@@ -139,17 +139,7 @@ const compression = async (filename, dry, quiet = false) => {
139
139
  logMessage(`${status} ${filename} (${details})`, dry, color, quiet)
140
140
 
141
141
  if (dry) {
142
- await retryFileOperation(() => fs.promises.unlink(tempFilePath))
143
- return 0
144
- }
145
-
146
- // Clean up temp file only when it wasn’t consumed
147
- if (!tempConsumed) {
148
- try {
149
- await retryFileOperation(() => fs.promises.unlink(tempFilePath))
150
- } catch (err) {
151
- if (err.code !== 'ENOENT') throw err
152
- }
142
+ return 0 // Temp file cleaned up in finally
153
143
  }
154
144
 
155
145
  if (fileSizeAfter === 0) {
@@ -160,15 +150,17 @@ const compression = async (filename, dry, quiet = false) => {
160
150
 
161
151
  } catch (err) {
162
152
 
163
- // Check if this is a file corruption error
164
- if (err.message && (
165
- err.message.includes('corrupt header') ||
166
- err.message.includes('Unexpected end of') ||
167
- err.message.includes('Invalid') ||
168
- err.message.includes('gifload:') ||
169
- err.message.includes('pngload:') ||
170
- err.message.includes('jpegload:')
171
- )) {
153
+ // Check if this is a file corruption error (sharp/libvips error messages)
154
+ const msg = err.message ? err.message.toLowerCase() : ''
155
+ if (msg.includes('corrupt header') ||
156
+ msg.includes('unexpected end of') ||
157
+ msg.includes('invalid image') ||
158
+ msg.includes('gifload:') ||
159
+ msg.includes('pngload:') ||
160
+ msg.includes('jpegload:') ||
161
+ msg.includes('webpload:') ||
162
+ msg.includes('avifload:')
163
+ ) {
172
164
  logMessage(`Skipped ${filename} (corrupt file)`, dry, 'yellow', quiet)
173
165
  } else {
174
166
  console.error(styleText('red', `Error compressing ${filename}:`), err)
@@ -177,15 +169,32 @@ const compression = async (filename, dry, quiet = false) => {
177
169
 
178
170
  } finally {
179
171
 
172
+ // Clean up temp file if it wasn’t consumed (covers dry-run, error, and no-improvement paths)
173
+ if (!tempConsumed) {
174
+ try {
175
+ await retryFileOperation(() => fs.unlink(tempFilePath))
176
+ } catch {
177
+ // Best-effort cleanup—ignore all errors to avoid masking the original error
178
+ }
179
+ }
180
+
180
181
  // If backup created (i.e., only in improvement path), try to remove it
181
182
  if (!dry && replacementSucceeded) {
182
183
  try {
183
- await retryFileOperation(() => fs.promises.unlink(filenameBackup))
184
+ await retryFileOperation(() => fs.unlink(filenameBackup))
184
185
  } catch (err) {
185
186
  if (err.code !== 'ENOENT') {
186
187
  console.warn(styleText('yellow', `Failed to delete backup file ${filenameBackup}:`), err)
187
188
  }
188
189
  }
190
+ } else if (!dry && !replacementSucceeded) {
191
+ // If a backup was created but replacement failed, warn so the user can recover it
192
+ try {
193
+ await fs.access(filenameBackup)
194
+ console.warn(styleText('yellow', `Replacement failed for ${filename}; backup preserved at ${filenameBackup}`))
195
+ } catch {
196
+ // No backup exists—nothing to warn about
197
+ }
189
198
  }
190
199
 
191
200
  }
@@ -199,9 +208,7 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
199
208
  return 0
200
209
  }
201
210
 
202
- const maxFileSize = 100 * 1024 * 1024 // 100 MB
203
-
204
- if (fileSizeBefore > maxFileSize) {
211
+ if (fileSizeBefore > MAX_FILE_SIZE) {
205
212
  logMessage(`Skipped ${filename} (file too large: ${sizeReadable(fileSizeBefore)})`, dry, 'yellow', quiet)
206
213
  return 0
207
214
  }
@@ -215,7 +222,7 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
215
222
 
216
223
  // Avoid overwriting an existing AVIF file
217
224
  try {
218
- await fs.promises.access(avifPath)
225
+ await fs.access(avifPath)
219
226
  logMessage(`Skipped ${filename} (${path.basename(avifPath)} already exists)`, dry, 'yellow', quiet)
220
227
  return 0
221
228
  } catch {
@@ -224,7 +231,7 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
224
231
 
225
232
  try {
226
233
  // Decode HEIC/HEIF to raw pixel data
227
- const inputBuffer = await fs.promises.readFile(filename)
234
+ const inputBuffer = await fs.readFile(filename)
228
235
  const { width, height, data } = await decode({ buffer: inputBuffer })
229
236
 
230
237
  // Encode as lossy AVIF—HEIC sources are already lossy (HEVC), so lossless
@@ -238,13 +245,13 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
238
245
  logMessage(`Converted ${filename} → ${path.basename(avifPath)} (${sizeReadable(fileSizeBefore)} → ${sizeReadable(fileSizeAfter)})`, dry, 'cyan', quiet)
239
246
 
240
247
  if (dry) {
241
- await retryFileOperation(() => fs.promises.unlink(avifPath))
248
+ await retryFileOperation(() => fs.unlink(avifPath))
242
249
  return 0
243
250
  }
244
251
 
245
252
  // Delete original HEIC/HEIF file unless `--keep-heic` is set
246
253
  if (!keepOriginal) {
247
- await retryFileOperation(() => fs.promises.unlink(filename))
254
+ await retryFileOperation(() => fs.unlink(filename))
248
255
  }
249
256
 
250
257
  return fileSizeAfter < fileSizeBefore ? fileSizeBefore - fileSizeAfter : 0
@@ -252,7 +259,7 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
252
259
  } catch (err) {
253
260
  // Clean up partial AVIF if it was created
254
261
  try {
255
- await fs.promises.unlink(avifPath)
262
+ await retryFileOperation(() => fs.unlink(avifPath))
256
263
  } catch (cleanupErr) {
257
264
  if (cleanupErr.code !== 'ENOENT') {
258
265
  console.warn(styleText('yellow', `Failed to clean up ${avifPath}:`), cleanupErr)
@@ -272,7 +279,7 @@ const conversion = async (filename, dry, keepOriginal, quiet = false) => {
272
279
  }
273
280
 
274
281
  const size = async (file) => {
275
- const stats = await fs.promises.stat(file)
282
+ const stats = await fs.stat(file)
276
283
  return stats.size
277
284
  }
278
285