@mifort-solutions/qmetrix 1.0.0

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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +75 -0
  3. package/package.json +51 -0
  4. package/src/audit-structure.mjs +103 -0
  5. package/src/bundle-codebase.mjs +626 -0
  6. package/src/check-images.mjs +125 -0
  7. package/src/coverage/clean.mjs +32 -0
  8. package/src/coverage/merge-istanbul.mjs +161 -0
  9. package/src/coverage/next-start-cov.mjs +38 -0
  10. package/src/coverage/report-global.mjs +90 -0
  11. package/src/coverage/report-suite.mjs +148 -0
  12. package/src/coverage/src-filter.mjs +50 -0
  13. package/src/dashboard/collectors/code.mjs +104 -0
  14. package/src/dashboard/collectors/composition-meta.mjs +295 -0
  15. package/src/dashboard/collectors/composition-transitions.mjs +0 -0
  16. package/src/dashboard/collectors/composition.mjs +360 -0
  17. package/src/dashboard/collectors/coverage.mjs +98 -0
  18. package/src/dashboard/collectors/deps.mjs +187 -0
  19. package/src/dashboard/collectors/entities.mjs +147 -0
  20. package/src/dashboard/collectors/graph.mjs +105 -0
  21. package/src/dashboard/collectors/lint.mjs +117 -0
  22. package/src/dashboard/collectors/routing.mjs +82 -0
  23. package/src/dashboard/collectors/security.mjs +182 -0
  24. package/src/dashboard/collectors/storybook.mjs +33 -0
  25. package/src/dashboard/config.mjs +15 -0
  26. package/src/dashboard/render/client.mjs +178 -0
  27. package/src/dashboard/render/components.mjs +247 -0
  28. package/src/dashboard/render/composition.mjs +192 -0
  29. package/src/dashboard/render/styles.mjs +217 -0
  30. package/src/dashboard/render/template.mjs +283 -0
  31. package/src/dashboard/utils/exec.mjs +29 -0
  32. package/src/dashboard/utils/format.mjs +32 -0
  33. package/src/dashboard/utils/fs.mjs +48 -0
  34. package/src/e2e-server-guard.mjs +283 -0
  35. package/src/optimize-images.mjs +231 -0
  36. package/src/quality-dashboard.mjs +291 -0
  37. package/src/security-scan.mjs +267 -0
  38. package/src/test-outline.mjs +98 -0
@@ -0,0 +1,283 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Guards the e2e suite against a stale `next start` server and a poisoned
4
+ * prerender cache on the Playwright port.
5
+ *
6
+ * The PW config reuses an existing server locally (reuseExistingServer), so a
7
+ * server started BEFORE the latest `next build` keeps serving HTML that
8
+ * references chunk files which no longer exist on disk — JS fails to load,
9
+ * React never hydrates, and every interaction-dependent test times out.
10
+ * Worse, while such a server runs, its ISR revalidations WRITE old-build HTML
11
+ * into the new build's prerender cache (.next/server/app), so even a freshly
12
+ * started server then serves broken pages for those routes (Next serves the
13
+ * stale cache entry first and only re-renders in the background).
14
+ *
15
+ * Two modes:
16
+ * - default: health-check a running server by fetching the home page and
17
+ * requesting the /_next/static assets it references (a fresh server serves
18
+ * its own assets; a stale one 404/500s them), then scan the prerender cache
19
+ * for entries referencing assets missing from .next/static and purge them.
20
+ * - --pre-build: a `next build` is about to run, which makes ANY currently
21
+ * running local server stale-after-build (and lets it poison the new
22
+ * build's cache via ISR). Kill it unconditionally.
23
+ *
24
+ * Outcomes (both modes): exit 0 when the port is safe for Playwright, exit 1
25
+ * with instructions when the port is held by something unrecognized. Runs from
26
+ * the npm scripts (test:e2e, coverage:e2e); invoking `playwright test`
27
+ * directly bypasses it. Skipped when PW_BASE_URL targets a remote site.
28
+ */
29
+ import { execFileSync } from 'node:child_process';
30
+ import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
31
+ import path from 'node:path';
32
+
33
+ const PORT = Number(process.env.PORT ?? 3000);
34
+ const BASE = `http://localhost:${PORT}`;
35
+ const MAX_ASSETS = 10;
36
+ const PRE_BUILD = process.argv.includes('--pre-build');
37
+ // Anchored on the consuming repo's cwd — every verb runs from the app root.
38
+ const rootDir = process.cwd();
39
+ const log = (msg) => console.log(`[e2e-server-guard] ${msg}`);
40
+
41
+ if (process.env.PW_BASE_URL) {
42
+ log(`PW_BASE_URL is set (${process.env.PW_BASE_URL}) - remote run, nothing to guard.`);
43
+ process.exit(0);
44
+ }
45
+
46
+ const isConnRefused = (err) => err?.cause?.code === 'ECONNREFUSED' || err?.code === 'ECONNREFUSED';
47
+
48
+ async function probeHome() {
49
+ try {
50
+ const res = await fetch(`${BASE}/`, { signal: AbortSignal.timeout(8000) });
51
+ if (!res.ok) {
52
+ return { state: 'stale', reason: `GET / returned HTTP ${res.status}` };
53
+ }
54
+ return { state: 'up', html: await res.text() };
55
+ } catch (err) {
56
+ if (isConnRefused(err)) {
57
+ return { state: 'free' };
58
+ }
59
+ return { state: 'stale', reason: `GET / failed (${err.cause?.code ?? err.name})` };
60
+ }
61
+ }
62
+
63
+ async function findBrokenAssets(html) {
64
+ const assets = [...new Set(html.match(/\/_next\/static\/[^"'\\ )?#]+\.(?:js|css)/g) ?? [])];
65
+ if (assets.length === 0) {
66
+ return [`no /_next/static assets found in the served HTML - not this app's Next server?`];
67
+ }
68
+ const broken = [];
69
+ for (const asset of assets.slice(0, MAX_ASSETS)) {
70
+ try {
71
+ const res = await fetch(BASE + asset, { signal: AbortSignal.timeout(8000) });
72
+ if (!res.ok) {
73
+ broken.push(`${asset} -> HTTP ${res.status}`);
74
+ }
75
+ } catch (err) {
76
+ broken.push(`${asset} -> ${err.cause?.code ?? err.name}`);
77
+ }
78
+ }
79
+ return broken;
80
+ }
81
+
82
+ function findListeningPids() {
83
+ if (process.platform === 'win32') {
84
+ const out = execFileSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' });
85
+ const re = new RegExp(`TCP\\s+\\S*:${PORT}\\s+\\S+\\s+LISTENING\\s+(\\d+)`, 'g');
86
+ return [...new Set([...out.matchAll(re)].map((m) => Number(m[1])))];
87
+ }
88
+ try {
89
+ const out = execFileSync('lsof', ['-ti', `tcp:${PORT}`, '-sTCP:LISTEN'], { encoding: 'utf8' });
90
+ return [...new Set(out.split('\n').filter(Boolean).map(Number))];
91
+ } catch {
92
+ return []; // lsof exits non-zero when nothing matches
93
+ }
94
+ }
95
+
96
+ function commandLineOf(pid) {
97
+ try {
98
+ if (process.platform === 'win32') {
99
+ return execFileSync(
100
+ 'powershell',
101
+ [
102
+ '-NoProfile',
103
+ '-Command',
104
+ `(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CommandLine`,
105
+ ],
106
+ { encoding: 'utf8' },
107
+ ).trim();
108
+ }
109
+ return execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' }).trim();
110
+ } catch {
111
+ return '';
112
+ }
113
+ }
114
+
115
+ function killPid(pid) {
116
+ if (process.platform === 'win32') {
117
+ execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
118
+ } else {
119
+ process.kill(pid, 'SIGTERM');
120
+ }
121
+ }
122
+
123
+ async function waitForPortFree() {
124
+ for (let i = 0; i < 20; i++) {
125
+ try {
126
+ await fetch(`${BASE}/`, { signal: AbortSignal.timeout(1000) });
127
+ } catch (err) {
128
+ if (isConnRefused(err)) {
129
+ return true;
130
+ }
131
+ }
132
+ await new Promise((r) => setTimeout(r, 500));
133
+ }
134
+ return false;
135
+ }
136
+
137
+ function killListeningServer(reason) {
138
+ log(`killing server on :${PORT}: ${reason}`);
139
+ const pids = findListeningPids();
140
+ if (pids.length === 0) {
141
+ log('could not find the owning process; free the port manually and retry.');
142
+ process.exit(1);
143
+ }
144
+ for (const pid of pids) {
145
+ const cmd = commandLineOf(pid);
146
+ // Only kill processes that are recognizably this stack's dev/prod server.
147
+ if (!/next|node/i.test(cmd)) {
148
+ log(
149
+ `port :${PORT} is held by PID ${pid} (${cmd || 'unknown command'}) - not a Next/Node server, refusing to kill it.`,
150
+ );
151
+ log('stop it manually (or set PW_BASE_URL to target it intentionally) and retry.');
152
+ process.exit(1);
153
+ }
154
+ log(`killing PID ${pid} (${cmd})`);
155
+ killPid(pid);
156
+ }
157
+ }
158
+
159
+ // --- prerender-cache integrity ------------------------------------------------
160
+ // A stale server's ISR writes leave .next/server/app entries whose HTML/RSC
161
+ // reference assets from the previous build. Detect them by checking every
162
+ // /_next/static (or flight-payload "static/...") reference against the files
163
+ // actually present in .next/static, and purge the whole route entry
164
+ // (.html/.rsc/.meta/.segments) so the fresh server re-renders it on demand.
165
+
166
+ function* walkFiles(dir) {
167
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
168
+ const p = path.join(dir, entry.name);
169
+ if (entry.isDirectory()) {
170
+ yield* walkFiles(p);
171
+ } else {
172
+ yield p;
173
+ }
174
+ }
175
+ }
176
+
177
+ function staticRefsOf(text) {
178
+ const refs = new Set();
179
+ for (const m of text.match(/\/_next\/static\/[^"'\\ )?#]+/g) ?? []) {
180
+ refs.add(m.slice('/_next/static/'.length));
181
+ }
182
+ for (const m of text.match(/\bstatic\/(?:chunks|css|media)\/[^"'\\ )?#]+/g) ?? []) {
183
+ refs.add(m.slice('static/'.length));
184
+ }
185
+ return refs;
186
+ }
187
+
188
+ function purgePoisonedPrerenders() {
189
+ const appDir = path.join(rootDir, '.next', 'server', 'app');
190
+ const staticDir = path.join(rootDir, '.next', 'static');
191
+ const poisoned = new Set();
192
+ if (!existsSync(appDir) || !existsSync(staticDir)) {
193
+ return poisoned;
194
+ }
195
+ const segMarker = `.segments${path.sep}`;
196
+ for (const file of walkFiles(appDir)) {
197
+ if (!/\.(html|rsc)$/.test(file)) {
198
+ continue;
199
+ }
200
+ // Files inside <route>.segments/ belong to that route; purge at route level.
201
+ const base = file.includes(segMarker)
202
+ ? file.slice(0, file.indexOf(segMarker))
203
+ : file.replace(/\.(prefetch\.rsc|rsc|html)$/, '');
204
+ if (poisoned.has(base)) {
205
+ continue;
206
+ }
207
+ for (const ref of staticRefsOf(readFileSync(file, 'utf8'))) {
208
+ if (!existsSync(path.join(staticDir, ref))) {
209
+ poisoned.add(base);
210
+ log(`poisoned prerender: ${path.relative(rootDir, file)} references missing static/${ref}`);
211
+ break;
212
+ }
213
+ }
214
+ }
215
+ for (const base of poisoned) {
216
+ for (const suffix of ['.html', '.rsc', '.prefetch.rsc', '.meta']) {
217
+ rmSync(base + suffix, { force: true });
218
+ }
219
+ rmSync(base + '.segments', { recursive: true, force: true });
220
+ log(`purged stale prerender entry: ${path.relative(rootDir, base)}.* (re-rendered on demand)`);
221
+ }
222
+ if (poisoned.size === 0) {
223
+ log('prerender cache is consistent with .next/static.');
224
+ }
225
+ return poisoned;
226
+ }
227
+
228
+ // --- main ----------------------------------------------------------------------
229
+
230
+ if (PRE_BUILD) {
231
+ // A rebuild is imminent: any running local server WILL be stale afterwards
232
+ // and would poison the new build's prerender cache via ISR writes.
233
+ if (findListeningPids().length === 0) {
234
+ log(`nothing listening on :${PORT} - safe to build.`);
235
+ } else {
236
+ killListeningServer('a new build is about to replace .next under it');
237
+ if (!(await waitForPortFree())) {
238
+ log(`port :${PORT} is still busy after kill; free it manually and retry.`);
239
+ process.exit(1);
240
+ }
241
+ log('server killed - safe to build.');
242
+ }
243
+ process.exit(0);
244
+ }
245
+
246
+ const probe = await probeHome();
247
+ let needsKill = null;
248
+ if (probe.state === 'stale') {
249
+ needsKill = probe.reason;
250
+ } else if (probe.state === 'up') {
251
+ const broken = await findBrokenAssets(probe.html);
252
+ if (broken.length > 0) {
253
+ needsKill = `serves assets that fail to load:\n ${broken.join('\n ')}`;
254
+ }
255
+ }
256
+
257
+ if (needsKill) {
258
+ killListeningServer(needsKill);
259
+ if (!(await waitForPortFree())) {
260
+ log(`port :${PORT} is still busy after kill; free it manually and retry.`);
261
+ process.exit(1);
262
+ }
263
+ log('stale server killed - Playwright will start a fresh one.');
264
+ purgePoisonedPrerenders();
265
+ } else if (probe.state === 'free') {
266
+ log(`nothing listening on :${PORT} - Playwright will start a fresh server.`);
267
+ purgePoisonedPrerenders();
268
+ } else {
269
+ // Server looks healthy, but if the on-disk prerender cache held poisoned
270
+ // entries the server would still serve them (and may have them in memory) —
271
+ // in that case restart it on top of the purged cache.
272
+ const poisoned = purgePoisonedPrerenders();
273
+ if (poisoned.size > 0) {
274
+ killListeningServer('its in-memory cache may hold the purged poisoned entries');
275
+ if (!(await waitForPortFree())) {
276
+ log(`port :${PORT} is still busy after kill; free it manually and retry.`);
277
+ process.exit(1);
278
+ }
279
+ log('server restart forced - Playwright will start a fresh one on the purged cache.');
280
+ } else {
281
+ log(`server on :${PORT} serves its own assets - safe to reuse.`);
282
+ }
283
+ }
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Image optimizer — resize + recompress committed assets with sharp (bundled with Next 16).
4
+ *
5
+ * Downscales anything larger than --max-px (never upscales, keeps aspect ratio) and
6
+ * re-encodes at --quality. By default keeps each file's format and overwrites in place;
7
+ * with --format it writes a converted copy alongside the original (update references,
8
+ * then pass --replace to delete the source file).
9
+ *
10
+ * Usage:
11
+ * node dev/scripts/optimize-images.mjs [targets…] [options]
12
+ * npm run images:optimize -- public/images --format webp
13
+ *
14
+ * Targets: image files or directories, searched recursively (default: public/)
15
+ * Options:
16
+ * --max-px <n> longest side after resize (default 2560, no upscaling)
17
+ * --quality <n> encoder quality 1–100 (default 80)
18
+ * --format <f> keep | webp | avif | jpeg | png (default keep)
19
+ * --replace after a --format conversion, delete the original file
20
+ * --dry-run encode and report savings, but write nothing
21
+ *
22
+ * SVG and GIF are skipped (vector / animation — not sharp's job).
23
+ */
24
+
25
+ import { readdirSync, statSync, existsSync } from 'node:fs';
26
+ import { readFile, writeFile, unlink } from 'node:fs/promises';
27
+ import path from 'node:path';
28
+ import sharp from 'sharp';
29
+
30
+ // libvips' file cache holds input handles open, which breaks in-place overwrites
31
+ // on Windows (EBUSY/EPERM on write). We read inputs into buffers ourselves, but
32
+ // disable the cache too so no handle ever lingers.
33
+ sharp.cache(false);
34
+
35
+ // Anchored on the consuming repo's cwd — every verb runs from the app root.
36
+ const ROOT = process.cwd();
37
+
38
+ const RASTER_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.avif']);
39
+ const SKIPPED_EXT = new Set(['.svg', '.gif']);
40
+ const FORMATS = new Set(['keep', 'webp', 'avif', 'jpeg', 'png']);
41
+
42
+ /* ── CLI parsing ── */
43
+ const argv = process.argv.slice(2);
44
+ const targets = [];
45
+ let maxPx = 2560;
46
+ let quality = 80;
47
+ let format = 'keep';
48
+ let replace = false;
49
+ let dryRun = false;
50
+
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const a = argv[i];
53
+ if (a === '--max-px') {
54
+ maxPx = Number(argv[++i]);
55
+ } else if (a === '--quality') {
56
+ quality = Number(argv[++i]);
57
+ } else if (a === '--format') {
58
+ format = String(argv[++i]).toLowerCase();
59
+ } else if (a === '--replace') {
60
+ replace = true;
61
+ } else if (a === '--dry-run') {
62
+ dryRun = true;
63
+ } else if (a.startsWith('--')) {
64
+ console.error(`Unknown option: ${a} (see header of dev/scripts/optimize-images.mjs)`);
65
+ process.exit(2);
66
+ } else {
67
+ targets.push(a);
68
+ }
69
+ }
70
+ if (!FORMATS.has(format) || !(maxPx > 0) || !(quality >= 1 && quality <= 100)) {
71
+ console.error('Invalid options — format keep|webp|avif|jpeg|png, max-px > 0, quality 1–100.');
72
+ process.exit(2);
73
+ }
74
+ if (targets.length === 0) {
75
+ targets.push('public');
76
+ }
77
+
78
+ /* ── helpers ── */
79
+ const rel = (p) => path.relative(ROOT, p).split(path.sep).join('/');
80
+ const fmtKB = (bytes) =>
81
+ bytes >= 1024 * 1024
82
+ ? `${(bytes / 1024 / 1024).toFixed(1)} MB`
83
+ : bytes < 1024
84
+ ? `${bytes} B`
85
+ : `${Math.round(bytes / 1024)} KB`;
86
+
87
+ // Same-format recompression is lossy; don't rewrite a file for a marginal win.
88
+ const MIN_GAIN = 0.05;
89
+
90
+ function* walk(dir) {
91
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
92
+ if (entry.isDirectory()) {
93
+ yield* walk(path.join(dir, entry.name));
94
+ } else {
95
+ yield path.join(dir, entry.name);
96
+ }
97
+ }
98
+ }
99
+
100
+ function collectFiles() {
101
+ const files = [];
102
+ for (const t of targets) {
103
+ const abs = path.isAbsolute(t) ? t : path.join(ROOT, t);
104
+ if (!existsSync(abs)) {
105
+ console.error(`✗ Not found: ${t}`);
106
+ process.exit(2);
107
+ }
108
+ if (statSync(abs).isDirectory()) {
109
+ files.push(...walk(abs));
110
+ } else {
111
+ files.push(abs);
112
+ }
113
+ }
114
+ return files.filter((f) => {
115
+ const ext = path.extname(f).toLowerCase();
116
+ if (SKIPPED_EXT.has(ext)) {
117
+ console.log(` ⊘ ${rel(f)} — ${ext.slice(1)} skipped (not handled by this tool)`);
118
+ return false;
119
+ }
120
+ return RASTER_EXT.has(ext);
121
+ });
122
+ }
123
+
124
+ // PNG quality requires palette quantization; only engage it below lossless quality.
125
+ function encode(pipeline, fmt) {
126
+ if (fmt === 'jpeg') {
127
+ return pipeline.jpeg({ quality, mozjpeg: true });
128
+ }
129
+ if (fmt === 'webp') {
130
+ return pipeline.webp({ quality });
131
+ }
132
+ if (fmt === 'avif') {
133
+ return pipeline.avif({ quality });
134
+ }
135
+ if (fmt === 'png') {
136
+ return quality < 100
137
+ ? pipeline.png({ compressionLevel: 9, adaptiveFiltering: true, palette: true, quality })
138
+ : pipeline.png({ compressionLevel: 9, adaptiveFiltering: true });
139
+ }
140
+ throw new Error(`No encoder for format "${fmt}"`);
141
+ }
142
+
143
+ const EXT_FOR = { jpeg: '.jpg', png: '.png', webp: '.webp', avif: '.avif' };
144
+
145
+ async function optimize(file) {
146
+ const input = await readFile(file); // buffer in, so the source file holds no open handle
147
+ const inBytes = input.length;
148
+ const meta = await sharp(input).metadata();
149
+ const srcFormat = meta.format === 'jpg' ? 'jpeg' : meta.format;
150
+ const outFormat = format === 'keep' ? srcFormat : format;
151
+ if (!EXT_FOR[outFormat]) {
152
+ console.log(` ⊘ ${rel(file)} — source format "${srcFormat}" not supported, skipped`);
153
+ return null;
154
+ }
155
+
156
+ const converting = outFormat !== srcFormat;
157
+ const willResize = Math.max(meta.width ?? 0, meta.height ?? 0) > maxPx;
158
+
159
+ // A palette PNG has already been through quantization (likely a previous run of
160
+ // this tool) — re-encoding would stack generation loss for pennies. Idempotency guard.
161
+ if (!converting && !willResize && srcFormat === 'png' && meta.isPalette) {
162
+ console.log(` = ${rel(file)} — already quantized (${fmtKB(inBytes)})`);
163
+ return { saved: 0 };
164
+ }
165
+
166
+ const pipeline = sharp(input)
167
+ .rotate() // bake EXIF orientation in before any resize
168
+ .resize({ width: maxPx, height: maxPx, fit: 'inside', withoutEnlargement: true });
169
+ const buf = await encode(pipeline, outFormat).toBuffer();
170
+
171
+ const outFile = converting
172
+ ? path.join(path.dirname(file), path.basename(file, path.extname(file)) + EXT_FOR[outFormat])
173
+ : file;
174
+
175
+ // Same format and no resize needed: rewriting is a lossy generation — only
176
+ // worth it when the size win is real, not a rounding error.
177
+ if (!converting && !willResize && buf.length >= inBytes * (1 - MIN_GAIN)) {
178
+ console.log(` = ${rel(file)} — already optimal (${fmtKB(inBytes)})`);
179
+ return { saved: 0 };
180
+ }
181
+
182
+ if (!dryRun) {
183
+ await writeFile(outFile, buf);
184
+ if (converting && replace) {
185
+ await unlink(file);
186
+ }
187
+ }
188
+
189
+ const pct = Math.round((1 - buf.length / inBytes) * 100);
190
+ const dims = willResize ? ` ${meta.width}×${meta.height}→≤${maxPx}px` : '';
191
+ const arrow = converting ? ` → ${rel(outFile)}` : '';
192
+ console.log(
193
+ ` ${dryRun ? '·' : '✓'} ${rel(file)}${arrow}${dims} ${fmtKB(inBytes)} → ${fmtKB(buf.length)} (−${pct}%)${dryRun ? ' [dry-run]' : ''}`,
194
+ );
195
+ return { saved: inBytes - buf.length };
196
+ }
197
+
198
+ /* ── main ── */
199
+ const files = collectFiles();
200
+ console.log(
201
+ `\n== Image optimizer == (${files.length} files · max ${maxPx}px · q${quality} · format=${format}${dryRun ? ' · DRY RUN' : ''})\n`,
202
+ );
203
+ if (files.length === 0) {
204
+ console.log('Nothing to do.\n');
205
+ process.exit(0);
206
+ }
207
+
208
+ let totalSaved = 0;
209
+ let failures = 0;
210
+ for (const file of files) {
211
+ try {
212
+ const r = await optimize(file);
213
+ if (r) {
214
+ totalSaved += Math.max(0, r.saved);
215
+ }
216
+ } catch (err) {
217
+ failures++;
218
+ console.log(` ✗ ${rel(file)} — ${err.message}`);
219
+ }
220
+ }
221
+
222
+ console.log(`\n${dryRun ? 'Would save' : 'Saved'} ${fmtKB(totalSaved)} total.`);
223
+ if (format !== 'keep') {
224
+ console.log(
225
+ replace
226
+ ? 'Originals deleted (--replace) — update any code/markdown references to the new extension.'
227
+ : 'Converted copies written alongside originals — update references, then re-run with --replace.',
228
+ );
229
+ }
230
+ console.log('');
231
+ process.exit(failures ? 1 : 0);