apex-auditor 0.3.3 → 0.3.5

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/README.md CHANGED
@@ -16,10 +16,20 @@ From your web project root:
16
16
  pnpm dlx apex-auditor@latest
17
17
  ```
18
18
 
19
+ Notes:
20
+
21
+ - `init` can auto-detect your stack from `package.json` (Next.js, Nuxt, Remix/React Router, SvelteKit, SPA).
22
+ - In monorepos, `init` can prompt you to pick an app/package under `apps/` or `packages/`.
23
+
19
24
  Inside the interactive shell:
20
25
 
21
26
  - **measure**
22
27
  - **audit**
28
+ - **bundle** (scan build output sizes; writes `.apex-auditor/bundle-audit.json`)
29
+ - **health** (HTTP status/latency checks; writes `.apex-auditor/health.json`)
30
+ - **links** (broken links crawl; writes `.apex-auditor/links.json`)
31
+ - **headers** (security headers check; writes `.apex-auditor/headers.json`)
32
+ - **console** (console errors + runtime exceptions; writes `.apex-auditor/console.json`)
23
33
  - **open** (open the latest HTML report)
24
34
  - **init** (launch config wizard)
25
35
  - **config <path>** (switch config file)
@@ -65,6 +75,26 @@ Notes:
65
75
  - `measure-summary.json`
66
76
  - `measure/` (screenshots and artifacts)
67
77
 
78
+ ### `bundle` outputs
79
+
80
+ - `bundle-audit.json`
81
+
82
+ ### `health` outputs
83
+
84
+ - `health.json`
85
+
86
+ ### `links` outputs
87
+
88
+ - `links.json`
89
+
90
+ ### `headers` outputs
91
+
92
+ - `headers.json`
93
+
94
+ ### `console` outputs
95
+
96
+ - `console.json`
97
+
68
98
  ## Configuration
69
99
 
70
100
  ApexAuditor reads `apex.config.json` by default.
package/dist/bin.js CHANGED
@@ -4,6 +4,12 @@ import { runWizardCli } from "./wizard-cli.js";
4
4
  import { runQuickstartCli } from "./quickstart-cli.js";
5
5
  import { runShellCli } from "./shell-cli.js";
6
6
  import { runMeasureCli } from "./measure-cli.js";
7
+ import { runBundleCli } from "./bundle-cli.js";
8
+ import { runHealthCli } from "./health-cli.js";
9
+ import { runLinksCli } from "./links-cli.js";
10
+ import { runHeadersCli } from "./headers-cli.js";
11
+ import { runConsoleCli } from "./console-cli.js";
12
+ import { runCleanCli } from "./clean-cli.js";
7
13
  function parseBinArgs(argv) {
8
14
  const rawCommand = argv[2];
9
15
  if (rawCommand === undefined) {
@@ -18,6 +24,12 @@ function parseBinArgs(argv) {
18
24
  }
19
25
  if (rawCommand === "audit" ||
20
26
  rawCommand === "measure" ||
27
+ rawCommand === "bundle" ||
28
+ rawCommand === "health" ||
29
+ rawCommand === "links" ||
30
+ rawCommand === "headers" ||
31
+ rawCommand === "console" ||
32
+ rawCommand === "clean" ||
21
33
  rawCommand === "wizard" ||
22
34
  rawCommand === "quickstart" ||
23
35
  rawCommand === "guide" ||
@@ -108,6 +120,12 @@ function printHelp(topic) {
108
120
  " guide Same as wizard, with inline tips for non-technical users",
109
121
  " audit Run Lighthouse audits using apex.config.json",
110
122
  " measure Fast batch metrics (CDP-based, non-Lighthouse)",
123
+ " bundle Bundle size audit (Next.js .next/ or dist/ build output)",
124
+ " health HTTP status + latency checks for configured routes",
125
+ " links Broken links audit (sitemap + HTML link extraction)",
126
+ " headers Security headers audit",
127
+ " console Console errors + runtime exceptions audit (headless Chrome)",
128
+ " clean Remove ApexAuditor artifacts (reports/cache and optionally config)",
111
129
  " help Show this help message",
112
130
  "",
113
131
  "Options (audit):",
@@ -127,7 +145,7 @@ function printHelp(topic) {
127
145
  " --yes, -y Auto-confirm large runs (bypass safety prompt)",
128
146
  " --changed-only Run only pages whose paths match files in git diff --name-only (working tree diff)",
129
147
  " --rerun-failing Re-run only combos that failed in the previous summary (runtime errors or perf<90)",
130
- " --accessibility-pass Run a fast axe-core accessibility sweep after audits (lightweight, CDP-based)",
148
+ " --accessibility-pass Opt-in: run a fast axe-core accessibility sweep after audits (lightweight, CDP-based)",
131
149
  " --webhook-url <url> Send a JSON webhook with regressions/budgets/accessibility (regressions-only summary)",
132
150
  " --show-parallel Print the resolved parallel workers before running.",
133
151
  " --incremental Reuse cached results for unchanged combos (requires --build-id). Opt-in; off by default.",
@@ -138,6 +156,57 @@ function printHelp(topic) {
138
156
  " --accurate Preset: devtools throttling + warm-up + stability-first (parallel=1 unless overridden)",
139
157
  " --open Open the HTML report after the run.",
140
158
  "",
159
+ "Options (measure):",
160
+ " --mobile-only Run measure only for 'mobile' devices defined in the config",
161
+ " --desktop-only Run measure only for 'desktop' devices defined in the config",
162
+ " --parallel <n> Override parallel workers (1-10).",
163
+ " --timeout-ms <ms> Per-navigation timeout in milliseconds (default 60000)",
164
+ " --screenshots Opt-in: save a screenshot per combo (slower; writes .apex-auditor/measure/*.png)",
165
+ " --json Print JSON summary to stdout",
166
+ "",
167
+ "Options (bundle):",
168
+ " --project-root <path> Project root to scan (default cwd)",
169
+ " --top <n> Show top N largest files (default 15)",
170
+ " --json Print JSON report to stdout",
171
+ "",
172
+ "Options (health):",
173
+ " --config <path> Config path (default apex.config.json)",
174
+ " --parallel <n> Parallel requests (default auto)",
175
+ " --timeout-ms <ms> Per-request timeout (default 20000)",
176
+ " --json Print JSON report to stdout",
177
+ "",
178
+ "Options (links):",
179
+ " --config <path> Config path (default apex.config.json)",
180
+ " --sitemap <url> Override sitemap URL (default <baseUrl>/sitemap.xml)",
181
+ " --parallel <n> Parallel requests (default auto)",
182
+ " --timeout-ms <ms> Per-request timeout (default 20000)",
183
+ " --max-urls <n> Limit total URLs checked (default 200)",
184
+ " --json Print JSON report to stdout",
185
+ "",
186
+ "Options (headers):",
187
+ " --config <path> Config path (default apex.config.json)",
188
+ " --parallel <n> Parallel requests (default auto)",
189
+ " --timeout-ms <ms> Per-request timeout (default 20000)",
190
+ " --json Print JSON report to stdout",
191
+ "",
192
+ "Options (console):",
193
+ " --config <path> Config path (default apex.config.json)",
194
+ " --parallel <n> Parallel workers (default auto)",
195
+ " --timeout-ms <ms> Per-navigation timeout (default 60000)",
196
+ " --max-events <n> Cap captured events per combo (default 50)",
197
+ " --json Print JSON report to stdout",
198
+ "",
199
+ "Options (clean):",
200
+ " --project-root <path> Project root (default cwd)",
201
+ " --config-path <path> Config file path relative to project root (default apex.config.json)",
202
+ " --reports Remove .apex-auditor/ (default)",
203
+ " --no-reports Keep .apex-auditor/",
204
+ " --remove-config Remove config file",
205
+ " --all Remove reports and config",
206
+ " --dry-run Print planned removals without deleting",
207
+ " --yes, -y Skip confirmation prompt",
208
+ " --json Print JSON report to stdout",
209
+ "",
141
210
  "Outputs:",
142
211
  " - Writes .apex-auditor/summary.json, summary.md, report.html",
143
212
  " - Prints a file:// link to the HTML report after completion",
@@ -159,6 +228,9 @@ function printHelp(topic) {
159
228
  " apex-auditor help budgets",
160
229
  ].join("\n"));
161
230
  }
231
+ function isInteractiveTty() {
232
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
233
+ }
162
234
  export async function runBin(argv) {
163
235
  const parsed = parseBinArgs(argv);
164
236
  if (parsed.command === "help") {
@@ -172,63 +244,63 @@ export async function runBin(argv) {
172
244
  }
173
245
  if (parsed.command === "quickstart") {
174
246
  await runQuickstartCli(parsed.argv);
247
+ if (isInteractiveTty()) {
248
+ await runShellCli(["node", "apex-auditor"]);
249
+ }
175
250
  return;
176
251
  }
177
- if (parsed.command === "audit") {
178
- try {
252
+ const runOnce = async () => {
253
+ if (parsed.command === "audit") {
179
254
  await runAuditCli(parsed.argv);
255
+ return;
180
256
  }
181
- catch (error) {
182
- const message = error instanceof Error ? error.message : String(error);
183
- if (message.includes("ENOENT")) {
184
- // eslint-disable-next-line no-console
185
- console.error("Config file not found. Run `apex-auditor init` to create a config or set one with `config <path>`.");
186
- return;
187
- }
188
- throw error;
257
+ if (parsed.command === "measure") {
258
+ await runMeasureCli(parsed.argv);
259
+ return;
189
260
  }
190
- if (process.stdin.isTTY && process.stdout.isTTY) {
191
- // eslint-disable-next-line no-console
192
- console.log("\nAudit completed. Press Ctrl+C to exit, or enter another command (type help for options).");
193
- await runShellCli(["node", "apex-auditor"]);
261
+ if (parsed.command === "bundle") {
262
+ await runBundleCli(parsed.argv);
263
+ return;
194
264
  }
195
- return;
196
- }
197
- if (parsed.command === "measure") {
198
- try {
199
- await runMeasureCli(parsed.argv);
265
+ if (parsed.command === "health") {
266
+ await runHealthCli(parsed.argv);
267
+ return;
200
268
  }
201
- catch (error) {
202
- const message = error instanceof Error ? error.message : String(error);
203
- if (message.includes("ENOENT")) {
204
- // eslint-disable-next-line no-console
205
- console.error("Config file not found. Run `apex-auditor init` to create a config or set one with `config <path>`.");
206
- return;
207
- }
208
- throw error;
269
+ if (parsed.command === "links") {
270
+ await runLinksCli(parsed.argv);
271
+ return;
209
272
  }
210
- if (process.stdin.isTTY && process.stdout.isTTY) {
211
- // eslint-disable-next-line no-console
212
- console.log("\nMeasure run completed. Press Ctrl+C to exit, or enter another command (type help for options).");
213
- await runShellCli(["node", "apex-auditor"]);
273
+ if (parsed.command === "headers") {
274
+ await runHeadersCli(parsed.argv);
275
+ return;
214
276
  }
215
- return;
277
+ if (parsed.command === "console") {
278
+ await runConsoleCli(parsed.argv);
279
+ return;
280
+ }
281
+ if (parsed.command === "clean") {
282
+ await runCleanCli(parsed.argv);
283
+ return;
284
+ }
285
+ if (parsed.command === "init" || parsed.command === "wizard" || parsed.command === "guide") {
286
+ await runWizardCli(parsed.argv);
287
+ return;
288
+ }
289
+ };
290
+ try {
291
+ await runOnce();
216
292
  }
217
- if (parsed.command === "init") {
218
- await runWizardCli(parsed.argv);
219
- if (process.stdin.isTTY && process.stdout.isTTY) {
293
+ catch (error) {
294
+ const message = error instanceof Error ? error.message : String(error);
295
+ if (message.includes("ENOENT")) {
220
296
  // eslint-disable-next-line no-console
221
- console.log("\nConfig initialized. Press Ctrl+C to exit, or enter another command (type help for options).");
222
- await runShellCli(["node", "apex-auditor"]);
297
+ console.error("Config file not found. Run `apex-auditor init` to create a config or set one with `config <path>`.");
298
+ return;
223
299
  }
224
- return;
300
+ throw error;
225
301
  }
226
- if (parsed.command === "wizard" || parsed.command === "guide") {
227
- await runWizardCli(parsed.argv);
228
- if (process.stdin.isTTY && process.stdout.isTTY) {
229
- await runShellCli(["node", "apex-auditor"]);
230
- }
231
- return;
302
+ if (isInteractiveTty()) {
303
+ await runShellCli(["node", "apex-auditor"]);
232
304
  }
233
305
  }
234
306
  void runBin(process.argv).catch((error) => {
@@ -0,0 +1,158 @@
1
+ import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
2
+ import { join, relative, resolve } from "node:path";
3
+ import { renderPanel } from "./ui/render-panel.js";
4
+ import { renderTable } from "./ui/render-table.js";
5
+ import { UiTheme } from "./ui/ui-theme.js";
6
+ import { stopSpinner } from "./spinner.js";
7
+ const NO_COLOR = Boolean(process.env.NO_COLOR) || process.env.CI === "true";
8
+ const theme = new UiTheme({ noColor: NO_COLOR });
9
+ function formatBytes(bytes) {
10
+ const kb = bytes / 1024;
11
+ if (kb < 1024) {
12
+ return `${Math.round(kb)}KB`;
13
+ }
14
+ const mb = kb / 1024;
15
+ return `${mb.toFixed(1)}MB`;
16
+ }
17
+ async function pathExists(path) {
18
+ try {
19
+ await stat(path);
20
+ return true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ async function walkFiles(params) {
27
+ const entries = [];
28
+ const visitDir = async (dir) => {
29
+ if (params.signal?.aborted) {
30
+ throw new Error("Aborted");
31
+ }
32
+ const children = await readdir(dir);
33
+ for (const name of children) {
34
+ if (params.signal?.aborted) {
35
+ throw new Error("Aborted");
36
+ }
37
+ const absolute = join(dir, name);
38
+ const s = await stat(absolute);
39
+ if (s.isDirectory()) {
40
+ await visitDir(absolute);
41
+ continue;
42
+ }
43
+ const matchedExt = params.extensions.find((ext) => name.endsWith(ext));
44
+ if (!matchedExt) {
45
+ continue;
46
+ }
47
+ const kind = matchedExt === ".css" ? "css" : "js";
48
+ const rel = relative(params.base, absolute).replace(/\\/g, "/");
49
+ entries.push({ kind, relativePath: rel, bytes: s.size });
50
+ }
51
+ };
52
+ await visitDir(params.root);
53
+ return entries;
54
+ }
55
+ function parseArgs(argv) {
56
+ let projectRoot = process.cwd();
57
+ let jsonOutput = false;
58
+ let top = 15;
59
+ for (let i = 2; i < argv.length; i += 1) {
60
+ const arg = argv[i];
61
+ if ((arg === "--project-root" || arg === "--root") && i + 1 < argv.length) {
62
+ projectRoot = argv[i + 1] ?? projectRoot;
63
+ i += 1;
64
+ }
65
+ else if (arg === "--json") {
66
+ jsonOutput = true;
67
+ }
68
+ else if (arg === "--top" && i + 1 < argv.length) {
69
+ const value = parseInt(argv[i + 1] ?? "", 10);
70
+ if (!Number.isFinite(value) || value <= 0 || value > 100) {
71
+ throw new Error(`Invalid --top value: ${argv[i + 1]}. Expected 1-100.`);
72
+ }
73
+ top = value;
74
+ i += 1;
75
+ }
76
+ }
77
+ return { projectRoot, jsonOutput, top };
78
+ }
79
+ function buildTopFilesTable(entries, top) {
80
+ const rows = [...entries]
81
+ .sort((a, b) => b.bytes - a.bytes)
82
+ .slice(0, top)
83
+ .map((f) => [f.kind, formatBytes(f.bytes), f.relativePath]);
84
+ if (rows.length === 0) {
85
+ return "";
86
+ }
87
+ return renderTable({ headers: ["Type", "Size", "File"], rows });
88
+ }
89
+ export async function runBundleCli(argv, options) {
90
+ stopSpinner();
91
+ const args = parseArgs(argv);
92
+ const projectRoot = resolve(args.projectRoot);
93
+ const nextDir = resolve(projectRoot, ".next");
94
+ const distDir = resolve(projectRoot, "dist");
95
+ const hasNext = await pathExists(nextDir);
96
+ const hasDist = await pathExists(distDir);
97
+ const scanTargets = [
98
+ hasNext ? resolve(nextDir, "static") : "",
99
+ hasDist ? distDir : "",
100
+ ].filter((p) => p.length > 0);
101
+ const allEntries = [];
102
+ for (const target of scanTargets) {
103
+ if (options?.signal?.aborted) {
104
+ throw new Error("Aborted");
105
+ }
106
+ const entries = await walkFiles({
107
+ root: target,
108
+ base: projectRoot,
109
+ extensions: [".js", ".css"],
110
+ signal: options?.signal,
111
+ });
112
+ allEntries.push(...entries);
113
+ }
114
+ const jsBytes = allEntries.filter((e) => e.kind === "js").reduce((sum, e) => sum + e.bytes, 0);
115
+ const cssBytes = allEntries.filter((e) => e.kind === "css").reduce((sum, e) => sum + e.bytes, 0);
116
+ const topFiles = [...allEntries].sort((a, b) => b.bytes - a.bytes).slice(0, args.top);
117
+ const report = {
118
+ meta: {
119
+ projectRoot,
120
+ scannedAt: new Date().toISOString(),
121
+ detected: { nextDir: hasNext, distDir: hasDist },
122
+ },
123
+ totals: {
124
+ jsBytes,
125
+ cssBytes,
126
+ fileCount: allEntries.length,
127
+ },
128
+ topFiles,
129
+ files: allEntries,
130
+ };
131
+ const outputDir = resolve(projectRoot, ".apex-auditor");
132
+ const outputPath = resolve(outputDir, "bundle-audit.json");
133
+ await mkdir(outputDir, { recursive: true });
134
+ await writeFile(outputPath, JSON.stringify(report, null, 2), "utf8");
135
+ if (args.jsonOutput) {
136
+ // eslint-disable-next-line no-console
137
+ console.log(JSON.stringify(report, null, 2));
138
+ return;
139
+ }
140
+ const detectedText = [hasNext ? ".next" : "", hasDist ? "dist" : ""].filter((v) => v.length > 0).join(", ") || "(none)";
141
+ const lines = [
142
+ `Project: ${projectRoot}`,
143
+ `Detected: ${detectedText}`,
144
+ `Files: ${report.totals.fileCount}`,
145
+ `JS: ${formatBytes(report.totals.jsBytes)}`,
146
+ `CSS: ${formatBytes(report.totals.cssBytes)}`,
147
+ `Output: ${relative(projectRoot, outputPath).replace(/\\/g, "/")}`,
148
+ ];
149
+ // eslint-disable-next-line no-console
150
+ console.log(renderPanel({ title: theme.bold("Bundle"), lines }));
151
+ const table = buildTopFilesTable(allEntries, args.top);
152
+ if (table.length > 0) {
153
+ // eslint-disable-next-line no-console
154
+ console.log(`\n${theme.bold(`Largest files (top ${args.top})`)}`);
155
+ // eslint-disable-next-line no-console
156
+ console.log(table);
157
+ }
158
+ }
@@ -0,0 +1,159 @@
1
+ import { rm } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import readline from "node:readline";
4
+ function parseArgs(argv) {
5
+ let projectRoot = process.cwd();
6
+ let configPath = "apex.config.json";
7
+ let removeReports = true;
8
+ let removeConfig = false;
9
+ let dryRun = false;
10
+ let yes = false;
11
+ let jsonOutput = false;
12
+ for (let i = 2; i < argv.length; i += 1) {
13
+ const arg = argv[i] ?? "";
14
+ if (arg === "--project-root" && i + 1 < argv.length) {
15
+ projectRoot = argv[i + 1] ?? projectRoot;
16
+ i += 1;
17
+ continue;
18
+ }
19
+ if ((arg === "--config-path" || arg === "--config") && i + 1 < argv.length) {
20
+ configPath = argv[i + 1] ?? configPath;
21
+ i += 1;
22
+ continue;
23
+ }
24
+ if (arg === "--reports") {
25
+ removeReports = true;
26
+ continue;
27
+ }
28
+ if (arg === "--no-reports") {
29
+ removeReports = false;
30
+ continue;
31
+ }
32
+ if (arg === "--remove-config") {
33
+ removeConfig = true;
34
+ continue;
35
+ }
36
+ if (arg === "--all") {
37
+ removeReports = true;
38
+ removeConfig = true;
39
+ continue;
40
+ }
41
+ if (arg === "--dry-run") {
42
+ dryRun = true;
43
+ continue;
44
+ }
45
+ if (arg === "--yes" || arg === "-y") {
46
+ yes = true;
47
+ continue;
48
+ }
49
+ if (arg === "--json") {
50
+ jsonOutput = true;
51
+ continue;
52
+ }
53
+ }
54
+ return {
55
+ projectRoot: resolve(projectRoot),
56
+ configPath: resolve(projectRoot, configPath),
57
+ removeReports,
58
+ removeConfig,
59
+ dryRun,
60
+ yes,
61
+ jsonOutput,
62
+ };
63
+ }
64
+ async function confirmPrompt(question) {
65
+ if (!process.stdin.isTTY) {
66
+ return false;
67
+ }
68
+ process.stdin.resume();
69
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
70
+ try {
71
+ const answer = await new Promise((resolvePromise) => {
72
+ rl.question(question, (value) => resolvePromise(value));
73
+ });
74
+ const text = answer.trim().toLowerCase();
75
+ return text === "y" || text === "yes";
76
+ }
77
+ finally {
78
+ rl.close();
79
+ }
80
+ }
81
+ function buildPlan(args) {
82
+ const actions = [];
83
+ if (args.removeReports) {
84
+ actions.push({ kind: "rm", path: resolve(args.projectRoot, ".apex-auditor"), existsByAssumption: true });
85
+ }
86
+ if (args.removeConfig) {
87
+ actions.push({ kind: "rm", path: args.configPath, existsByAssumption: true });
88
+ }
89
+ return actions;
90
+ }
91
+ async function executePlan(plan, dryRun) {
92
+ if (dryRun) {
93
+ return [];
94
+ }
95
+ const executed = [];
96
+ for (const action of plan) {
97
+ if (action.kind === "rm") {
98
+ await rm(action.path, { recursive: true, force: true });
99
+ executed.push(action);
100
+ }
101
+ }
102
+ return executed;
103
+ }
104
+ export async function runCleanCli(argv) {
105
+ const startedAtMs = Date.now();
106
+ const args = parseArgs(argv);
107
+ const planned = buildPlan(args);
108
+ if (planned.length === 0) {
109
+ const empty = {
110
+ meta: {
111
+ projectRoot: args.projectRoot,
112
+ configPath: args.configPath,
113
+ dryRun: args.dryRun,
114
+ startedAt: new Date(startedAtMs).toISOString(),
115
+ completedAt: new Date(startedAtMs).toISOString(),
116
+ elapsedMs: 0,
117
+ },
118
+ planned: [],
119
+ executed: [],
120
+ };
121
+ if (args.jsonOutput) {
122
+ console.log(JSON.stringify(empty, null, 2));
123
+ return;
124
+ }
125
+ console.log("Nothing to clean.");
126
+ return;
127
+ }
128
+ if (!args.yes && process.stdin.isTTY) {
129
+ const targets = planned.map((p) => p.path).join("\n");
130
+ const ok = await confirmPrompt(`This will remove:\n${targets}\nContinue? (y/N) `);
131
+ if (!ok) {
132
+ console.log("Cancelled.");
133
+ return;
134
+ }
135
+ }
136
+ const executed = await executePlan(planned, args.dryRun);
137
+ const completedAtMs = Date.now();
138
+ const report = {
139
+ meta: {
140
+ projectRoot: args.projectRoot,
141
+ configPath: args.configPath,
142
+ dryRun: args.dryRun,
143
+ startedAt: new Date(startedAtMs).toISOString(),
144
+ completedAt: new Date(completedAtMs).toISOString(),
145
+ elapsedMs: completedAtMs - startedAtMs,
146
+ },
147
+ planned,
148
+ executed,
149
+ };
150
+ if (args.jsonOutput) {
151
+ console.log(JSON.stringify(report, null, 2));
152
+ return;
153
+ }
154
+ if (args.dryRun) {
155
+ console.log(`Planned removals: ${planned.length} (dry-run).`);
156
+ return;
157
+ }
158
+ console.log(`Removed: ${executed.length}/${planned.length}.`);
159
+ }