bytecut 0.0.0-stage → 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 anupjon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,141 @@
1
- # Temporary Holding Version
1
+ # bytecut
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Audit and optimize the images and videos in a web project, from the terminal.
4
+
5
+ ```sh
6
+ npx bytecut
7
+ ```
8
+
9
+ With no configuration, bytecut detects your framework (Next.js, Astro, Vite or a static
10
+ site), scans the asset folders, shows what is too heavy and how much you would save, and
11
+ optimizes on Enter. Your original files are never modified: outputs go to a separate folder
12
+ that mirrors your source tree, and that folder is meant to be committed.
13
+
14
+ ## What you get
15
+
16
+ By default each image becomes **one JPEG**, with the longest side capped at 1920 px (a
17
+ landscape image is 1920 wide, a portrait one is 1920 tall, nothing is ever upscaled). Metadata
18
+ and GPS are stripped, orientation is applied and the colour profile is kept.
19
+
20
+ ```
21
+ public/img/hero.jpg → public/_bytecut/img/hero/hero-1920.jpg
22
+ public/_bytecut/bytecut-manifest.json
23
+ ```
24
+
25
+ - **Transparency is preserved.** A source that really uses alpha gets a lossless PNG instead of
26
+ a JPEG, and so does flat art such as icons and logos.
27
+ - **WebP and AVIF are opt-in** (see [Configuration](#configuration)).
28
+ - **A file is only written if it is smaller** than the source; otherwise it is reported as
29
+ "already optimal".
30
+ - **Videos and animated GIFs** become AV1 and H.264 MP4 plus a poster, using your own
31
+ `ffmpeg` (see [Video](#video)).
32
+
33
+ Re-running is cheap: unchanged sources are skipped by content hash, and the manifest is
34
+ byte-identical when nothing changed, so it diffs cleanly in pull requests.
35
+
36
+ ## Commands
37
+
38
+ | Command | What it does |
39
+ | ----------------------------- | ------------------------------------------------------------------------- |
40
+ | `bytecut` | Interactive session on a TTY; prints the audit elsewhere |
41
+ | `bytecut audit` | Report what is too heavy, with estimated savings |
42
+ | `bytecut optimize [paths] -y` | Write optimized files. Without `-y` it prints the plan and writes nothing |
43
+ | `bytecut snippet <file>` | Print the `<picture>` / `<video>` markup (HTML or JSX) for a file |
44
+ | `bytecut compare <file>` | Serve a before/after page on `127.0.0.1` (random port, private path) |
45
+ | `bytecut explain <file>` | Show which rule chose a file's preset and what it resolves to |
46
+ | `bytecut init` | Write a minimal `bytecut.config.json` with only non-default choices |
47
+ | `bytecut check` | CI mode: exit 1 if a finding is an error, 2 if it could not run |
48
+ | `bytecut doctor` | Report Node, sharp, ffmpeg, the scorer, and what is degraded |
49
+
50
+ Useful flags: `--dry-run --json` prints the plan as JSON and `--plan <file>` executes a saved
51
+ one; `--out-dir`, `--preset`, `--force`, `--placeholder dominant-color|lqip|blurhash|none`.
52
+
53
+ In the interactive session: `space` toggles a file, `enter` reviews and runs, `p` changes the
54
+ preset, `o` the output folder, `d` details, `esc` cancels, `?` help. Type `/` for commands
55
+ (`/snippet @file`, `/compare @file`, `/save`) and `@` to mention a file.
56
+
57
+ ## Configuration
58
+
59
+ Everything is optional. Config is only needed to make a choice stick. Put it in
60
+ `bytecut.config.json` (or `.ts`, `.mjs`, `.toml`); unknown keys are errors.
61
+
62
+ ```json
63
+ {
64
+ "presets": {
65
+ "photo": { "formats": ["jpg", "webp", "avif"] },
66
+ "gallery": {
67
+ "extends": "photo",
68
+ "maxEdge": 1280,
69
+ "ssimulacra2": 80,
70
+ "maxSize": "150 KB",
71
+ "floor": 65
72
+ },
73
+ "loop": { "extends": "background-video", "vmaf": 92 }
74
+ },
75
+ "assets": { "public/gallery/**": "gallery", "public/loops/*.mp4": "loop" },
76
+ "budgets": { "maxFileSize": "250 KB", "maxTotalSize": "5 MB" }
77
+ }
78
+ ```
79
+
80
+ - **Presets**: built-in `photo`, `hero`, `icon`, `background-video`, `content-video` and
81
+ `gif-to-video`. Add your own with `extends`; the first matching `assets` glob wins.
82
+ - **Size**: `maxEdge` caps the longest side; `widths` lists widths instead (a responsive set).
83
+ - **Formats**: `jpg` (default), `png`, `webp`, `avif`. A `<picture>` lists AVIF, then WebP, and
84
+ the JPEG is the `<img>`.
85
+ - **Quality**: `target` (`low`, `balanced`, `high`, `lossless`), or `quality` per format.
86
+ - **Perceptual targets**: see below.
87
+
88
+ ## Quality targets
89
+
90
+ Instead of a fixed quality you can ask for a score. bytecut then finds the **smallest** output
91
+ that still meets it:
92
+
93
+ - `ssimulacra2: 80` (images and video) searches quality on a representative crop, then
94
+ confirms on the full-size output.
95
+ - `maxSize` with a required `floor` lowers quality, then steps the width down, to fit a size
96
+ cap. A constraint that cannot be met writes nothing and fails the run; it is never silently
97
+ exceeded.
98
+ - `vmaf: 92` (video) uses ffmpeg's `libvmaf`. Without it, the preset's fixed quality is used
99
+ and a warning says so.
100
+
101
+ SSIMULACRA2 runs as WebAssembly in worker threads, so no native binary is needed. A native
102
+ `ssimulacra2` is used if one is found (`--ssimulacra2`, `BYTECUT_SSIMULACRA2`,
103
+ `ssimulacra2Path`, or `PATH`), as a speed-up only.
104
+
105
+ ## Video
106
+
107
+ bytecut uses **your own** `ffmpeg` and `ffprobe` (`--ffmpeg`, `BYTECUT_FFMPEG`, `ffmpegPath`, or
108
+ `PATH`). It never downloads or bundles one. Without ffmpeg images work fully and videos are
109
+ listed as "needs ffmpeg" with the install command for your OS. Without an AV1 encoder you still
110
+ get the H.264 file and the poster. `bytecut doctor` shows exactly what is available.
111
+
112
+ ## CI
113
+
114
+ ```sh
115
+ npx bytecut check # exit 1 on an error finding
116
+ npx bytecut check --fail-on warn # stricter
117
+ npx bytecut check --changed origin/main # only files changed since a ref
118
+ npx bytecut check --report md # a Markdown report for a PR comment
119
+ ```
120
+
121
+ `project/over-budget` is an error by default; other rules are promoted or silenced under
122
+ `audit.rules`.
123
+
124
+ ## Guarantees
125
+
126
+ - Sources are read-only. bytecut only writes inside the output folder (and the config file,
127
+ and only when you ask it to), atomically, so a cancelled run never leaves a half-written
128
+ file.
129
+ - It only deletes files listed in its manifest, and never without confirmation or an explicit flag.
130
+ - No network access at runtime: no telemetry, no update checks, no downloads.
131
+ - Same input, same settings and same tool versions give the same bytes where the encoder allows.
132
+
133
+ ## Requirements
134
+
135
+ Node.js 22 or newer. Images use [sharp](https://sharp.pixelplumbing.com). Developed and tested
136
+ on Linux; macOS and Windows have not been verified yet.
137
+
138
+ ## Licence
139
+
140
+ MIT. The bundled SSIMULACRA2 build (`bytecut-ssimulacra2`) is a WebAssembly compilation of
141
+ [rust-av/ssimulacra2](https://github.com/rust-av/ssimulacra2) and is BSD-2-Clause.
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ svgoEngine
4
+ } from "./chunk-LTWUPQDT.js";
5
+ import {
6
+ audit
7
+ } from "./chunk-4PWCQ4NF.js";
8
+ import {
9
+ formatAuditReport
10
+ } from "./chunk-P5PFI3AO.js";
11
+ import "./chunk-3DBJKHOD.js";
12
+ import "./chunk-TCQFQ354.js";
13
+ import {
14
+ sharpImageEngine
15
+ } from "./chunk-CSFBJ3UH.js";
16
+ import "./chunk-XUBQWBWH.js";
17
+ import {
18
+ configArg,
19
+ createVideoEngine,
20
+ discover,
21
+ ffmpegInstallText,
22
+ ffmpegStatus,
23
+ noAssetsMessage
24
+ } from "./chunk-KN5QOB7R.js";
25
+ import "./chunk-S3FQYUTI.js";
26
+ import "./chunk-QOG2F4SF.js";
27
+ import "./chunk-3PF6BBFK.js";
28
+ import "./chunk-IZ4JEL3Q.js";
29
+ import {
30
+ guarded
31
+ } from "./chunk-MV6J66AP.js";
32
+ import "./chunk-FY53B4NB.js";
33
+
34
+ // src/cli/commands/audit.ts
35
+ import { defineCommand } from "citty";
36
+ var audit_default = defineCommand({
37
+ meta: {
38
+ name: "audit",
39
+ description: "Report what is too heavy in your image assets, with estimated savings"
40
+ },
41
+ args: {
42
+ ...configArg,
43
+ json: { type: "boolean", description: "Print the report as JSON" },
44
+ all: { type: "boolean", description: "Show every finding instead of the first 10 per rule" },
45
+ "out-dir": {
46
+ type: "string",
47
+ description: "Output folder (default: from the detected framework)"
48
+ },
49
+ ffmpeg: {
50
+ type: "string",
51
+ description: "Path to the ffmpeg binary (else BYTECUT_FFMPEG, then PATH)"
52
+ },
53
+ ffprobe: { type: "string", description: "Path to the ffprobe binary" },
54
+ cache: {
55
+ type: "boolean",
56
+ default: true,
57
+ description: "Keep the analysis cache in <outDir>/.bytecut-cache (--no-cache to avoid writing)"
58
+ }
59
+ },
60
+ run: guarded(async ({ args }) => {
61
+ const found = await discover(args._.map(String), args["out-dir"], args.config);
62
+ const { config } = found;
63
+ if (found.scanInputs.length === 0) {
64
+ console.error(noAssetsMessage("audit", found.project));
65
+ process.exitCode = 2;
66
+ return;
67
+ }
68
+ const report = await audit({
69
+ projectRoot: found.projectRoot,
70
+ engine: sharpImageEngine,
71
+ svgEngine: svgoEngine,
72
+ inputs: found.scanInputs,
73
+ outDir: found.outDir,
74
+ staticRoot: found.project.staticRoot,
75
+ ffmpeg: ffmpegStatus(config),
76
+ videoEngine: createVideoEngine({ ffmpeg: args.ffmpeg, ffprobe: args.ffprobe }, config),
77
+ policy: config.policy,
78
+ exclude: config.exclude,
79
+ ignore: config.auditIgnore,
80
+ settings: config.rules,
81
+ budgets: config.budgets,
82
+ needsFfmpeg: ffmpegInstallText(),
83
+ persistCache: args.cache !== false
84
+ });
85
+ if (args.json) {
86
+ process.stdout.write(
87
+ `${JSON.stringify({ version: 1, project: found.project, ...report }, null, 2)}
88
+ `
89
+ );
90
+ return;
91
+ }
92
+ console.log(formatAuditReport(report, { all: Boolean(args.all) }));
93
+ })
94
+ });
95
+ export {
96
+ audit_default as default
97
+ };
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ svgoEngine
4
+ } from "./chunk-LTWUPQDT.js";
5
+ import {
6
+ audit
7
+ } from "./chunk-4PWCQ4NF.js";
8
+ import {
9
+ formatAuditReport
10
+ } from "./chunk-P5PFI3AO.js";
11
+ import "./chunk-3DBJKHOD.js";
12
+ import "./chunk-TCQFQ354.js";
13
+ import {
14
+ sharpImageEngine
15
+ } from "./chunk-CSFBJ3UH.js";
16
+ import "./chunk-XUBQWBWH.js";
17
+ import {
18
+ configArg,
19
+ createVideoEngine,
20
+ discover,
21
+ ffmpegInstallText,
22
+ ffmpegStatus,
23
+ noAssetsMessage
24
+ } from "./chunk-KN5QOB7R.js";
25
+ import {
26
+ run
27
+ } from "./chunk-S3FQYUTI.js";
28
+ import {
29
+ toPosix
30
+ } from "./chunk-QOG2F4SF.js";
31
+ import "./chunk-3PF6BBFK.js";
32
+ import "./chunk-IZ4JEL3Q.js";
33
+ import {
34
+ BytecutError,
35
+ CONFIG_FILES,
36
+ formatBytes,
37
+ percentChange,
38
+ plural
39
+ } from "./chunk-FY53B4NB.js";
40
+
41
+ // src/cli/commands/check.ts
42
+ import { defineCommand } from "citty";
43
+
44
+ // src/core/commands/check.ts
45
+ function evaluate(report, options = {}) {
46
+ const failOn = options.failOn ?? "error";
47
+ const changed = options.changed ?? null;
48
+ const inScope = (f) => !changed || changed.all || f.source === void 0 || changed.files.has(f.source);
49
+ const findings = report.findings.filter(inScope);
50
+ const failing = findings.filter(
51
+ (f) => f.severity === "error" || failOn === "warn" && f.severity === "warn"
52
+ );
53
+ const count = (s) => findings.filter((f) => f.severity === s).length;
54
+ return {
55
+ ok: failing.length === 0,
56
+ failOn,
57
+ findings,
58
+ failing,
59
+ counts: { error: count("error"), warn: count("warn"), info: count("info") },
60
+ scope: changed ? {
61
+ base: changed.base,
62
+ changedFiles: changed.files.size,
63
+ everything: changed.all,
64
+ dropped: report.findings.length - findings.length
65
+ } : null
66
+ };
67
+ }
68
+
69
+ // src/core/git/changed.ts
70
+ var fail = (message, cause) => new BytecutError("GIT_UNAVAILABLE", message, cause ? { cause } : void 0);
71
+ function nulList(stdout) {
72
+ return stdout.split("\0").filter((p) => p.length > 0);
73
+ }
74
+ async function changedFiles(options) {
75
+ const { projectRoot, base, run: run2 } = options;
76
+ const git = async (...args) => {
77
+ const { stdout } = await run2("git", ["-C", projectRoot, ...args]);
78
+ return stdout;
79
+ };
80
+ if (base.startsWith("-")) throw fail(`"${base}" is not a git ref.`);
81
+ try {
82
+ await git("rev-parse", "--is-inside-work-tree");
83
+ } catch (error) {
84
+ const missing = error.code === "ENOENT";
85
+ throw fail(
86
+ missing ? "--changed needs git, which was not found on PATH." : "--changed needs a git repository; this folder is not inside one.",
87
+ error
88
+ );
89
+ }
90
+ let mergeBase;
91
+ try {
92
+ await git("rev-parse", "--verify", "--quiet", `${base}^{commit}`);
93
+ } catch (error) {
94
+ const shallow = (await git("rev-parse", "--is-shallow-repository").catch(() => "")).trim();
95
+ throw fail(
96
+ `The ref "${base}" is not in this repository.` + (shallow === "true" ? " This is a shallow clone: fetch more history first (for GitHub Actions, `fetch-depth: 0` on actions/checkout)." : " bytecut never fetches; fetch it yourself if it is a remote branch."),
97
+ error
98
+ );
99
+ }
100
+ try {
101
+ mergeBase = (await git("merge-base", base, "HEAD")).trim();
102
+ } catch (error) {
103
+ throw fail(
104
+ `No common ancestor between "${base}" and HEAD. In a shallow clone, fetch more history (for GitHub Actions, \`fetch-depth: 0\`).`,
105
+ error
106
+ );
107
+ }
108
+ const tracked = nulList(
109
+ await git(
110
+ "diff",
111
+ "--name-only",
112
+ "--relative",
113
+ "--diff-filter=ACMRT",
114
+ "-z",
115
+ mergeBase,
116
+ "--",
117
+ "."
118
+ )
119
+ );
120
+ const untracked = nulList(
121
+ await git("ls-files", "--others", "--exclude-standard", "-z", "--", ".")
122
+ );
123
+ const files = new Set([...tracked, ...untracked].map(toPosix));
124
+ const configNames = new Set(CONFIG_FILES);
125
+ const all = [...files].some((f) => configNames.has(f));
126
+ return { base, files, all };
127
+ }
128
+
129
+ // src/core/report/json.ts
130
+ function renderJsonReport(report, verdict) {
131
+ return `${JSON.stringify(
132
+ {
133
+ version: 1,
134
+ ok: verdict.ok,
135
+ failOn: verdict.failOn,
136
+ counts: verdict.counts,
137
+ changed: verdict.scope,
138
+ findings: verdict.findings,
139
+ failing: verdict.failing.length,
140
+ scanned: report.counts,
141
+ totals: report.totals,
142
+ unprobed: report.unprobed,
143
+ notes: report.notes
144
+ },
145
+ null,
146
+ 2
147
+ )}
148
+ `;
149
+ }
150
+
151
+ // src/core/report/markdown.ts
152
+ var MARKDOWN_LIMIT = 6e4;
153
+ var ROWS_PER_RULE = 20;
154
+ var SEVERITY_ORDER = { error: 0, warn: 1, info: 2 };
155
+ function cell(text) {
156
+ return text.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/[`*_<>[\]]/g, (c) => `\\${c}`).replace(/\r?\n/g, " ");
157
+ }
158
+ function size(f) {
159
+ if (f.current !== void 0 && f.estimated !== void 0) {
160
+ return `${formatBytes(f.current)} \u2192 ~${formatBytes(f.estimated)} (${percentChange(f.current, f.estimated)})`;
161
+ }
162
+ return f.current !== void 0 ? formatBytes(f.current) : "";
163
+ }
164
+ function renderMarkdownReport(report, verdict) {
165
+ const out = [];
166
+ const { counts, scope } = verdict;
167
+ out.push(`## bytecut check: ${verdict.ok ? "passed" : "failed"}`, "");
168
+ const summary = [
169
+ `${plural(counts.error, "error")}, ${plural(counts.warn, "warning")}, ${counts.info} info`
170
+ ];
171
+ const c = report.counts;
172
+ summary.push(
173
+ `scanned ${plural(c.images + c.skipped, "image")}, ${plural(c.svgs, "SVG")}, ${plural(c.videos, "video")} (${formatBytes(report.totals.sourceBytes)})`
174
+ );
175
+ if (scope) {
176
+ summary.push(
177
+ scope.everything ? `a bytecut config changed since \`${cell(scope.base)}\`, so every file is in scope` : `${plural(scope.changedFiles, "file")} changed since \`${cell(scope.base)}\``
178
+ );
179
+ }
180
+ out.push(summary.join(" \xB7 "), "");
181
+ if (!verdict.ok) {
182
+ out.push(
183
+ `Failing on ${verdict.failOn === "warn" ? "warnings and errors" : "errors"}: ${plural(verdict.failing.length, "finding")}.`,
184
+ ""
185
+ );
186
+ }
187
+ const groups = /* @__PURE__ */ new Map();
188
+ for (const f of verdict.findings) groups.set(f.ruleId, [...groups.get(f.ruleId) ?? [], f]);
189
+ const ordered = [...groups.entries()].sort(([ia, a], [ib, b]) => {
190
+ const d = (SEVERITY_ORDER[a[0].severity] ?? 9) - (SEVERITY_ORDER[b[0].severity] ?? 9);
191
+ return d || (ia < ib ? -1 : 1);
192
+ });
193
+ const tail = [];
194
+ if (report.unprobed.length > 0) {
195
+ tail.push(
196
+ `> ${plural(report.unprobed.length, "video")} not analysed because ffmpeg was not found.`
197
+ );
198
+ }
199
+ for (const note of report.notes) tail.push(`> ${cell(note)}`);
200
+ let omitted = 0;
201
+ for (const [ruleId, findings] of ordered) {
202
+ const section = [
203
+ `### \`${ruleId}\` \xB7 ${findings[0].severity} \xB7 ${plural(findings.length, "file")}`,
204
+ "",
205
+ "| File | Size | Detail |",
206
+ "| --- | --- | --- |"
207
+ ];
208
+ for (const f of findings.slice(0, ROWS_PER_RULE)) {
209
+ const where = f.path ? `${f.source ?? ""} \u2192 ${f.path}` : f.source ?? "(project)";
210
+ section.push(`| ${cell(where)} | ${cell(size(f))} | ${cell(f.message)} |`);
211
+ }
212
+ if (findings.length > ROWS_PER_RULE) {
213
+ section.push("", `\u2026 and ${findings.length - ROWS_PER_RULE} more.`);
214
+ }
215
+ section.push("");
216
+ const text = section.join("\n");
217
+ if (out.join("\n").length + text.length + tail.join("\n").length + 200 > MARKDOWN_LIMIT) {
218
+ omitted += findings.length;
219
+ continue;
220
+ }
221
+ out.push(text);
222
+ }
223
+ if (omitted > 0) {
224
+ out.push(
225
+ `_${plural(omitted, "more finding")} not shown: this comment hit its size limit. Run \`bytecut check\` locally for the full list._`,
226
+ ""
227
+ );
228
+ }
229
+ out.push(...tail);
230
+ return `${out.join("\n").trimEnd()}
231
+ `;
232
+ }
233
+
234
+ // src/cli/commands/check.ts
235
+ var EXIT = { ok: 0, failed: 1, unusable: 2 };
236
+ var REPORTS = ["md", "json"];
237
+ var FAIL_ON = ["error", "warn"];
238
+ var check_default = defineCommand({
239
+ meta: {
240
+ name: "check",
241
+ description: "CI mode: audit the project and exit non-zero if any finding is an error"
242
+ },
243
+ args: {
244
+ ...configArg,
245
+ // Declared boolean so a bare `--changed` never swallows the flag after it; the base ref
246
+ // is read from the raw arguments below.
247
+ changed: {
248
+ type: "boolean",
249
+ description: "Only report files changed since a git ref: --changed (HEAD, i.e. local changes), --changed origin/main or --changed=origin/main"
250
+ },
251
+ report: { type: "string", description: "Print a report on stdout: md or json" },
252
+ "fail-on": {
253
+ type: "string",
254
+ description: "Lowest severity that fails the check: error (default) or warn"
255
+ },
256
+ all: { type: "boolean", description: "Text report: every finding, not the first 10 per rule" },
257
+ "out-dir": {
258
+ type: "string",
259
+ description: "Output folder (default: from the detected framework)"
260
+ },
261
+ ffmpeg: { type: "string", description: "Path to the ffmpeg binary" },
262
+ ffprobe: { type: "string", description: "Path to the ffprobe binary" }
263
+ },
264
+ run: async ({ args, rawArgs }) => {
265
+ try {
266
+ process.exitCode = await check(args, rawArgs);
267
+ } catch (error) {
268
+ if (!(error instanceof BytecutError)) throw error;
269
+ console.error(`bytecut: ${error.message} [${error.code}]`);
270
+ process.exitCode = EXIT.unusable;
271
+ }
272
+ }
273
+ });
274
+ async function check(args, rawArgs) {
275
+ const report = args.report;
276
+ if (report !== void 0 && !REPORTS.includes(report)) {
277
+ throw new BytecutError("BAD_ARGUMENTS", `--report must be md or json, not "${report}".`);
278
+ }
279
+ const failOn = args["fail-on"] ?? "error";
280
+ if (!FAIL_ON.includes(failOn)) {
281
+ throw new BytecutError("BAD_ARGUMENTS", `--fail-on must be error or warn, not "${failOn}".`);
282
+ }
283
+ let changedBase = null;
284
+ const positional = args._.map(String);
285
+ const at = rawArgs.findIndex((a) => a === "--changed" || a.startsWith("--changed="));
286
+ if (at !== -1) {
287
+ const arg = rawArgs[at];
288
+ const next = rawArgs[at + 1];
289
+ if (arg.includes("=")) changedBase = arg.slice(arg.indexOf("=") + 1) || "HEAD";
290
+ else if (next !== void 0 && !next.startsWith("-")) {
291
+ changedBase = next;
292
+ const i = positional.indexOf(next);
293
+ if (i !== -1) positional.splice(i, 1);
294
+ } else changedBase = "HEAD";
295
+ }
296
+ const found = await discover(
297
+ positional,
298
+ args["out-dir"],
299
+ args.config
300
+ );
301
+ if (found.scanInputs.length === 0) {
302
+ console.error(noAssetsMessage("check", found.project));
303
+ return EXIT.unusable;
304
+ }
305
+ const { config } = found;
306
+ const changed = changedBase === null ? null : await changedFiles({ projectRoot: found.projectRoot, base: changedBase, run });
307
+ const result = await audit({
308
+ projectRoot: found.projectRoot,
309
+ engine: sharpImageEngine,
310
+ svgEngine: svgoEngine,
311
+ inputs: found.scanInputs,
312
+ outDir: found.outDir,
313
+ staticRoot: found.project.staticRoot,
314
+ ffmpeg: ffmpegStatus(config),
315
+ videoEngine: createVideoEngine(
316
+ { ffmpeg: args.ffmpeg, ffprobe: args.ffprobe },
317
+ config
318
+ ),
319
+ needsFfmpeg: ffmpegInstallText(),
320
+ persistCache: false,
321
+ policy: config.policy,
322
+ exclude: config.exclude,
323
+ ignore: config.auditIgnore,
324
+ settings: config.rules,
325
+ budgets: config.budgets
326
+ });
327
+ const verdict = evaluate(result, { failOn, changed });
328
+ if (report === "json") process.stdout.write(renderJsonReport(result, verdict));
329
+ else if (report === "md") process.stdout.write(renderMarkdownReport(result, verdict));
330
+ else {
331
+ const view = { ...result, findings: verdict.findings };
332
+ console.log(formatAuditReport(view, { all: Boolean(args.all) }));
333
+ if (verdict.scope) {
334
+ console.log(
335
+ verdict.scope.everything ? `A bytecut config changed since ${verdict.scope.base}: every file is in scope.` : `${plural(verdict.scope.changedFiles, "file")} changed since ${verdict.scope.base}; findings about other files are not shown (${verdict.scope.dropped} hidden).`
336
+ );
337
+ }
338
+ }
339
+ const summary = `${plural(verdict.counts.error, "error")}, ${plural(verdict.counts.warn, "warning")}`;
340
+ console.error(
341
+ verdict.ok ? `bytecut check: passed (${summary}).` : `bytecut check: failed (${plural(verdict.failing.length, "failing finding")}; ${summary}).`
342
+ );
343
+ return verdict.ok ? EXIT.ok : EXIT.failed;
344
+ }
345
+ export {
346
+ EXIT,
347
+ check_default as default
348
+ };