@zgeoff/chex 0.0.1 → 0.0.2

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 (53) hide show
  1. package/README.md +96 -3
  2. package/lefthook.yml +123 -0
  3. package/package.json +31 -3
  4. package/src/build-init-package.ts +57 -0
  5. package/src/build-init-plan.ts +96 -0
  6. package/src/build-lefthook-extends.ts +117 -0
  7. package/src/check-names.ts +18 -0
  8. package/src/cli.ts +49 -0
  9. package/src/collect-command-names.ts +297 -0
  10. package/src/collect-expression-references.ts +17 -0
  11. package/src/dependency-gates/collect-stale-age-gate-excludes.ts +83 -0
  12. package/src/dependency-gates/collect-unneeded-audit-ignores.ts +41 -0
  13. package/src/dependency-gates/collect-unneeded-overrides.ts +70 -0
  14. package/src/dependency-gates/first-party-scopes.ts +1 -0
  15. package/src/dependency-gates/is-record.ts +4 -0
  16. package/src/dependency-gates/parse-audit-advisories.ts +33 -0
  17. package/src/dependency-gates/parse-audit-ignore-ids.ts +15 -0
  18. package/src/dependency-gates/parse-fixable-packages.ts +29 -0
  19. package/src/dependency-gates/parse-lockfile-dependents.ts +77 -0
  20. package/src/dependency-gates/parse-root-manifest.ts +68 -0
  21. package/src/dependency-gates/read-dependency-gate-findings.ts +91 -0
  22. package/src/dependency-gates/run-dependency-gates-check.ts +54 -0
  23. package/src/dependency-gates/types.ts +19 -0
  24. package/src/docs/check-length.sh +117 -0
  25. package/src/docs/check-links.sh +245 -0
  26. package/src/docs/check-prose.sh +657 -0
  27. package/src/docs/check-readmes.sh +102 -0
  28. package/src/docs/check-skill.sh +413 -0
  29. package/src/docs/check-vision.sh +34 -0
  30. package/src/find-install-cache-path.ts +16 -0
  31. package/src/format-commit-lines.ts +3 -0
  32. package/src/make-check-runner.ts +40 -0
  33. package/src/parse-added-lines.ts +112 -0
  34. package/src/parse-chex-args.ts +76 -0
  35. package/src/parse-lane-limits.ts +59 -0
  36. package/src/parse-pushed-refs.ts +29 -0
  37. package/src/parse-shell-words.ts +520 -0
  38. package/src/pick-merge-blobs-command.ts +152 -0
  39. package/src/read-branch-base.ts +31 -0
  40. package/src/read-branch-files.ts +59 -0
  41. package/src/read-pushed-ranges.ts +47 -0
  42. package/src/read-pushed-refs-input.ts +18 -0
  43. package/src/read-range-commits.ts +30 -0
  44. package/src/run-chex.ts +62 -0
  45. package/src/run-commits-check.ts +178 -0
  46. package/src/run-init.ts +129 -0
  47. package/src/run-local-paths-check.ts +185 -0
  48. package/src/run-program.ts +38 -0
  49. package/src/run-test-timeouts-check.ts +498 -0
  50. package/src/run-uncommitted-check.ts +82 -0
  51. package/src/run-workflows-check.ts +610 -0
  52. package/src/shell-escape-parsers.ts +197 -0
  53. package/src/write-text-file.ts +7 -0
@@ -0,0 +1,498 @@
1
+ import { basename, dirname, posix } from 'node:path';
2
+ import { parseLaneLimits } from './parse-lane-limits.ts';
3
+ import type { ShellWord } from './parse-shell-words.ts';
4
+ import { parseShellWords } from './parse-shell-words.ts';
5
+ import type { RunProgram } from './run-program.ts';
6
+ import { runProgram } from './run-program.ts';
7
+
8
+ export interface TestTimeoutsCheckOptions {
9
+ readonly cwd: string;
10
+ readonly env: Readonly<Record<string, string | undefined>>;
11
+ readonly runProgram: RunProgram;
12
+ }
13
+
14
+ export interface TestTimeoutFinding {
15
+ // Relative to the repo root.
16
+ readonly path: string;
17
+
18
+ readonly message: string;
19
+ }
20
+
21
+ // The check reads the files as HEAD holds them, so a push checks what it sends.
22
+ export function runTestTimeoutsCheck(
23
+ options: Readonly<TestTimeoutsCheckOptions>,
24
+ ): TestTimeoutFinding[] {
25
+ const paths = runGit(options, ['ls-tree', '-r', '-z', '--name-only', 'HEAD']).split('\0');
26
+
27
+ const configText = paths.includes(oxlintConfigPath)
28
+ ? runGit(options, ['show', `HEAD:${oxlintConfigPath}`])
29
+ : null;
30
+
31
+ const limits = parseLaneLimits(configText);
32
+
33
+ const appRoots = new Set(
34
+ paths.filter((path) => basename(path) === 'package.json').map((path) => dirname(path)),
35
+ );
36
+
37
+ return paths
38
+ .filter((path) => checkedFiles.has(basename(path)) || configPattern.test(basename(path)))
39
+ .flatMap((path) => {
40
+ const text = runGit(options, ['show', `HEAD:${path}`]);
41
+
42
+ if (configPattern.test(basename(path))) {
43
+ return collectConfigFindings(path, text);
44
+ }
45
+
46
+ return basename(path) === 'package.json'
47
+ ? collectScriptFindings(path, text, limits)
48
+ : collectBunfigFindings(
49
+ path,
50
+ text,
51
+ pickLaneLimit(limits, findDirectoryLane(path, appRoots)),
52
+ );
53
+ });
54
+ }
55
+
56
+ const configPattern = /^\.?oxlintrc(?:\.[\w-]+)?\.jsonc?$/u;
57
+ const oxlintConfigPath = '.oxlintrc.json';
58
+
59
+ const checkedFiles = new Set(['package.json', 'bunfig.toml']);
60
+
61
+ function runGit(options: Readonly<TestTimeoutsCheckOptions>, args: readonly string[]): string {
62
+ const result = options.runProgram(['git', ...args], { cwd: options.cwd, env: options.env });
63
+
64
+ if (result.exitCode !== 0) {
65
+ throw new Error(`git ${args.join(' ')} failed: ${result.stderr.trim()}`);
66
+ }
67
+
68
+ return result.stdout;
69
+ }
70
+
71
+ // A setting that turns a timeout rule off or down to a warning escapes the limits as a disable
72
+ // comment does, so it needs the same ticket, in a comment on its line or the lines above it.
73
+ function collectConfigFindings(path: string, text: string): TestTimeoutFinding[] {
74
+ const tokens = parseJSONCTokens(path, text);
75
+ const values = tokens.filter((token) => token.kind !== 'comment');
76
+ const comments = tokens.filter((token) => token.kind === 'comment');
77
+
78
+ const valueLines = new Set(values.map((token) => token.line));
79
+
80
+ const overrides = collectOverrideLines(values);
81
+
82
+ return values.flatMap((token, index) => {
83
+ const rule =
84
+ token.kind === 'string' ? guardedRules.find((name) => name === token.text) : undefined;
85
+
86
+ if (rule === undefined || values[index + 1]?.text !== ':') {
87
+ return [];
88
+ }
89
+
90
+ const first = values[index + 2];
91
+ const severity = first?.text === '[' ? values[index + 3] : first;
92
+
93
+ if (severity === undefined || !weakSeverities.has(severity.text)) {
94
+ return [];
95
+ }
96
+
97
+ const override = overrides.get(index);
98
+
99
+ const notes = comments.filter(
100
+ (comment) =>
101
+ (comment.line >= token.line && comment.line <= severity.line) ||
102
+ isCommentAbove(comment, token.line, valueLines) ||
103
+ (override !== undefined &&
104
+ (comment.line === override || isCommentAbove(comment, override, valueLines))),
105
+ );
106
+
107
+ if (
108
+ notes.some((comment) => ticketPattern.test(comment.text) || floorPattern.test(comment.text))
109
+ ) {
110
+ return [];
111
+ }
112
+
113
+ return [
114
+ {
115
+ path,
116
+ message: `line ${token.line} turns ${rule} off or down to a warning; give the setting a comment that names the ticket that tracks the fix, or "floor:" and the evidence that the time cannot move, as the "Slow tests" section of the testing skill describes`,
117
+ },
118
+ ];
119
+ });
120
+ }
121
+
122
+ const weakSeverities = new Set(['off', 'warn', 'allow', '0', '1']);
123
+
124
+ interface JSONCToken {
125
+ readonly kind: 'string' | 'comment' | 'other';
126
+
127
+ // A string's or a comment's content, or the token's own text.
128
+ readonly text: string;
129
+
130
+ // The 1-based lines the token starts and ends on.
131
+ readonly line: number;
132
+ readonly endLine: number;
133
+ }
134
+
135
+ function parseJSONCTokens(path: string, text: string): JSONCToken[] {
136
+ const tokens: JSONCToken[] = [];
137
+ let line = 1;
138
+ let index = 0;
139
+
140
+ const advance = (end: number): number => {
141
+ const start = line;
142
+
143
+ for (let at = index; at < end; at += 1) {
144
+ if (text.charAt(at) === '\n') {
145
+ line += 1;
146
+ }
147
+ }
148
+
149
+ index = end;
150
+
151
+ return start;
152
+ };
153
+
154
+ while (index < text.length) {
155
+ const character = text.charAt(index);
156
+ const next = text.charAt(index + 1);
157
+
158
+ if (/\s/u.test(character)) {
159
+ advance(index + 1);
160
+ } else if (character === '/' && (next === '/' || next === '*')) {
161
+ const close = next === '/' ? text.indexOf('\n', index) : text.indexOf('*/', index + 2);
162
+ const end = close === -1 ? text.length : close;
163
+ const body = text.slice(index + 2, end);
164
+ const after = next === '/' || close === -1 ? end : end + 2;
165
+ const start = advance(after);
166
+
167
+ tokens.push({ kind: 'comment', text: body, line: start, endLine: line });
168
+ } else if (character === '"') {
169
+ const end = getStringEnd(path, text, index);
170
+ const body = parseText(path, text.slice(index, end), (input) => JSON.parse(input));
171
+ const start = advance(end);
172
+
173
+ tokens.push({ kind: 'string', text: String(body), line: start, endLine: line });
174
+ } else {
175
+ const word = /^[\w.+-]+/u.exec(text.slice(index))?.[0] ?? character;
176
+ const start = advance(index + word.length);
177
+
178
+ tokens.push({ kind: 'other', text: word, line: start, endLine: line });
179
+ }
180
+ }
181
+
182
+ return tokens;
183
+ }
184
+
185
+ function getStringEnd(path: string, text: string, start: number): number {
186
+ for (let index = start + 1; index < text.length; index += 1) {
187
+ const character = text.charAt(index);
188
+
189
+ if (character === '\\') {
190
+ index += 1;
191
+ } else if (character === '"') {
192
+ return index + 1;
193
+ }
194
+ }
195
+
196
+ throw new Error(`cannot parse ${path}: a string never closes`);
197
+ }
198
+
199
+ function parseText(path: string, text: string, parse: (input: string) => unknown): unknown {
200
+ try {
201
+ return parse(text);
202
+ } catch (error) {
203
+ throw new Error(
204
+ `cannot parse ${path}: ${error instanceof Error ? error.message : String(error)}`,
205
+ { cause: error },
206
+ );
207
+ }
208
+ }
209
+
210
+ function collectOverrideLines(values: readonly JSONCToken[]): ReadonlyMap<number, number> {
211
+ const lines = new Map<number, number>();
212
+
213
+ const open: number[] = [];
214
+
215
+ for (const [index, token] of values.entries()) {
216
+ if (token.text === '{') {
217
+ open.push(index);
218
+ } else if (token.text === '}') {
219
+ open.pop();
220
+ }
221
+
222
+ const override = open.length >= 3 ? values[open[1] ?? 0] : undefined;
223
+
224
+ if (override !== undefined) {
225
+ lines.set(index, override.line);
226
+ }
227
+ }
228
+
229
+ return lines;
230
+ }
231
+
232
+ // a comment above the setting counts while no other value sits between them
233
+ function isCommentAbove(
234
+ comment: JSONCToken,
235
+ line: number,
236
+ valueLines: ReadonlySet<number>,
237
+ ): boolean {
238
+ if (comment.endLine >= line) {
239
+ return false;
240
+ }
241
+
242
+ for (let between = comment.endLine + 1; between < line; between += 1) {
243
+ if (valueLines.has(between)) {
244
+ return false;
245
+ }
246
+ }
247
+
248
+ return !valueLines.has(comment.line);
249
+ }
250
+
251
+ const unitLimitMs = 5000;
252
+ const slowTestsHint = 'find its cause in the "Slow tests" section of the testing skill and fix it';
253
+
254
+ function collectScriptFindings(
255
+ path: string,
256
+ text: string,
257
+ limits: ReadonlyMap<string, number>,
258
+ ): TestTimeoutFinding[] {
259
+ const scripts = findMember(
260
+ parseText(path, text, (input) => JSON.parse(input)),
261
+ 'scripts',
262
+ );
263
+
264
+ if (typeof scripts !== 'object' || scripts === null) {
265
+ return [];
266
+ }
267
+
268
+ return Object.entries(scripts).flatMap(([name, command]: readonly [string, unknown]) => {
269
+ if (typeof command !== 'string' || name === 'test:related') {
270
+ return [];
271
+ }
272
+
273
+ const lane = name.startsWith('test:') ? name.slice('test:'.length) : null;
274
+ const limit = pickLaneLimit(limits, lane);
275
+
276
+ return collectTimeoutValues(command).flatMap((timeoutMs) => {
277
+ const breach = findTimeoutBreach(timeoutMs, limit);
278
+
279
+ return breach === undefined
280
+ ? []
281
+ : [
282
+ {
283
+ path,
284
+ message: `script "${name}" passes --timeout=${timeoutMs} to bun test, ${breach}; ${slowTestsHint}`,
285
+ },
286
+ ];
287
+ });
288
+ });
289
+ }
290
+
291
+ function findMember(value: unknown, key: string): unknown {
292
+ return typeof value === 'object' && value !== null && Object.hasOwn(value, key)
293
+ ? Reflect.get(value, key)
294
+ : undefined;
295
+ }
296
+
297
+ interface LaneLimit {
298
+ readonly lane: string;
299
+ readonly limitMs: number;
300
+ }
301
+
302
+ function pickLaneLimit(limits: ReadonlyMap<string, number>, lane: string | null): LaneLimit {
303
+ const limitMs = lane === null ? undefined : limits.get(lane);
304
+
305
+ return lane === null || limitMs === undefined
306
+ ? { lane: 'unit', limitMs: unitLimitMs }
307
+ : { lane, limitMs };
308
+ }
309
+
310
+ // A wrapper such as `bash with-home.sh bun test` runs the words after it, and these only print them.
311
+ const printCommands = new Set(['echo', 'printf']);
312
+
313
+ // The `bun test` options that need a value take the next word whole, even one that starts with
314
+ // `--timeout`, as `bun test --help` lists them.
315
+ const valueOptions = new Set([
316
+ '-t',
317
+ '--test-name-pattern',
318
+ '--timeout',
319
+ '--rerun-each',
320
+ '--retry',
321
+ '--seed',
322
+ '--coverage-reporter',
323
+ '--coverage-dir',
324
+ '--reporter',
325
+ '--reporter-outfile',
326
+ '--max-concurrency',
327
+ '--path-ignore-patterns',
328
+ '--parallel-delay',
329
+ '--shard',
330
+ '--timings',
331
+ ]);
332
+
333
+ // `--timeout 20000` and `--timeout=20000` both reach `bun test`, and the shell removes any quotes or
334
+ // escapes from either form before Bun reads it. Bun reads each word after `--` as a file filter.
335
+ function collectTimeoutValues(command: string): number[] {
336
+ const values: number[] = [];
337
+
338
+ for (const words of collectShellCommands(parseShellWords(command))) {
339
+ const name = words.find((word) => !word.isAssignment);
340
+
341
+ if (name !== undefined && printCommands.has(name.text)) {
342
+ continue;
343
+ }
344
+
345
+ const texts = words.map((word) => word.text);
346
+ const start = findTestArgsStart(texts);
347
+
348
+ if (start === null) {
349
+ continue;
350
+ }
351
+
352
+ const args = texts.slice(start);
353
+
354
+ for (let index = 0; index < args.length; index += 1) {
355
+ const arg = args[index] ?? '';
356
+
357
+ if (arg === '--') {
358
+ break;
359
+ }
360
+
361
+ const value = arg === '--timeout' ? args[index + 1] : findTimeoutFlagValue(arg);
362
+ const timeoutMs = value === undefined ? null : parseTimeoutMs(value);
363
+
364
+ if (timeoutMs !== null) {
365
+ values.push(timeoutMs);
366
+ }
367
+
368
+ if (valueOptions.has(arg)) {
369
+ index += 1;
370
+ }
371
+ }
372
+ }
373
+
374
+ return values;
375
+ }
376
+
377
+ function collectShellCommands(words: readonly ShellWord[]): ShellWord[][] {
378
+ const commands: ShellWord[][] = [[]];
379
+
380
+ for (const word of words) {
381
+ if (word.isOperator) {
382
+ commands.push([]);
383
+ } else {
384
+ commands.at(-1)?.push(word);
385
+ }
386
+ }
387
+
388
+ return commands;
389
+ }
390
+
391
+ // Bun runs its test subcommand only when each option before it takes the `--name=value` form, so
392
+ // `bun --cwd pkg test` runs no test.
393
+ function findTestArgsStart(texts: readonly string[]): number | null {
394
+ for (const [index, text] of texts.entries()) {
395
+ const subcommand =
396
+ text === 'bun' ? texts.findIndex((word, at) => at > index && !word.startsWith('-')) : -1;
397
+
398
+ if (subcommand !== -1 && texts[subcommand] === 'test') {
399
+ return subcommand + 1;
400
+ }
401
+ }
402
+
403
+ return null;
404
+ }
405
+
406
+ function findTimeoutFlagValue(arg: string): string | undefined {
407
+ return arg.startsWith('--timeout=') ? arg.slice('--timeout='.length) : undefined;
408
+ }
409
+
410
+ // Bun 1.4 reads digits and underscores, and `0` turns the timeout off. It runs an empty value with
411
+ // the default timeout, and refuses any other value before a test runs.
412
+ function parseTimeoutMs(value: string): number | null {
413
+ return /^[\d_]+$/u.test(value) && /\d/u.test(value) ? Number(value.replaceAll('_', '')) : null;
414
+ }
415
+
416
+ // Bun runs a test with no timeout at all when the timeout is 0.
417
+ function findTimeoutBreach(timeoutMs: number, limit: LaneLimit): string | undefined {
418
+ const limitText = `the ${limit.limitMs} ms limit of the ${limit.lane} lane`;
419
+
420
+ if (timeoutMs === 0) {
421
+ return `which turns the timeout off, past ${limitText}`;
422
+ }
423
+
424
+ return timeoutMs > limit.limitMs ? `above ${limitText}` : undefined;
425
+ }
426
+
427
+ function collectBunfigFindings(path: string, text: string, limit: LaneLimit): TestTimeoutFinding[] {
428
+ const timeoutMs = findMember(
429
+ findMember(
430
+ parseText(path, text, (input) => Bun.TOML.parse(input)),
431
+ 'test',
432
+ ),
433
+ 'timeout',
434
+ );
435
+
436
+ if (typeof timeoutMs !== 'number') {
437
+ return [];
438
+ }
439
+
440
+ const breach = findTimeoutBreach(timeoutMs, limit);
441
+
442
+ return breach === undefined
443
+ ? []
444
+ : [
445
+ {
446
+ path,
447
+ message: `[test] timeout = ${timeoutMs} applies to every test, ${breach}; ${slowTestsHint}`,
448
+ },
449
+ ];
450
+ }
451
+
452
+ // git lists paths with forward slashes on every platform
453
+ function findDirectoryLane(path: string, appRoots: ReadonlySet<string>): string | null {
454
+ const directories = posix.dirname(path).split('/');
455
+
456
+ for (const [index, name] of directories.entries()) {
457
+ const parent = index === 0 ? '.' : directories.slice(0, index).join('/');
458
+
459
+ if (name === 'e2e' && (parent === '.' || appRoots.has(parent))) {
460
+ return 'e2e';
461
+ }
462
+
463
+ if (name.endsWith('-e2e') && appRoots.has(directories.slice(0, index + 1).join('/'))) {
464
+ return 'e2e';
465
+ }
466
+ }
467
+
468
+ return null;
469
+ }
470
+
471
+ const guardedRules = ['zgeoff/max-test-timeout', 'zgeoff/test-timeout-exception-ticket'];
472
+ const ticketPattern = /\b[A-Z][A-Z0-9]*-\d+\b/u;
473
+
474
+ // a block comment's continuation stars are no evidence
475
+ const floorPattern = /\bfloor:[\s*]*[^\s*]/u;
476
+
477
+ // Returns the exit code: 0 when clean, 1 with a finding, and 2 when the check cannot run.
478
+ export function runTestTimeoutsCommand(): number {
479
+ try {
480
+ const findings = runTestTimeoutsCheck({ cwd: process.cwd(), env: process.env, runProgram });
481
+
482
+ for (const finding of findings) {
483
+ process.stdout.write(`${finding.path}: ${finding.message}\n`);
484
+ }
485
+
486
+ return findings.length === 0 ? 0 : 1;
487
+ } catch (error) {
488
+ process.stderr.write(
489
+ `test-timeouts: ${error instanceof Error ? error.message : String(error)}\n`,
490
+ );
491
+
492
+ return 2;
493
+ }
494
+ }
495
+
496
+ if (import.meta.main) {
497
+ process.exitCode = runTestTimeoutsCommand();
498
+ }
@@ -0,0 +1,82 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { readBranchFiles } from './read-branch-files.ts';
4
+ import type { RunProgram } from './run-program.ts';
5
+ import { runProgram } from './run-program.ts';
6
+
7
+ export interface UncommittedCheckOptions {
8
+ readonly cwd: string;
9
+ readonly env: Readonly<Record<string, string | undefined>>;
10
+
11
+ // The files the push sends or deletes, relative to the repo root.
12
+ readonly files: readonly string[];
13
+
14
+ readonly runProgram: RunProgram;
15
+ readonly probeFile: (path: string) => boolean;
16
+ }
17
+
18
+ // The later checks read the disk, so a change to a pushed file that is not committed would hide a
19
+ // fault in the pushed commit. A file the branch deletes fails when it is back on disk, even under an
20
+ // ignore rule. Each git call lists every path, so no file name reaches its argv.
21
+ export function runUncommittedCheck(options: Readonly<UncommittedCheckOptions>): string[] {
22
+ // Each entry is two status letters, a space and the path.
23
+ const changed = new Set(
24
+ runGit(options, ['status', '--porcelain=v1', '-z', '--no-renames', '--untracked-files=no'])
25
+ .split('\0')
26
+ .map((entry) => entry.slice(3)),
27
+ );
28
+
29
+ const tracked = new Set(
30
+ runGit(options, ['ls-tree', '-r', '-z', '--full-tree', '--name-only', 'HEAD']).split('\0'),
31
+ );
32
+
33
+ return options.files.filter(
34
+ (path) =>
35
+ changed.has(path) || (!tracked.has(path) && options.probeFile(join(options.cwd, path))),
36
+ );
37
+ }
38
+
39
+ function runGit(options: Readonly<UncommittedCheckOptions>, args: readonly string[]): string {
40
+ const result = options.runProgram(['git', ...args], { cwd: options.cwd, env: options.env });
41
+
42
+ if (result.exitCode !== 0) {
43
+ throw new Error(`git ${args[0]} failed: ${result.stderr.trim()}`);
44
+ }
45
+
46
+ return result.stdout;
47
+ }
48
+
49
+ // Returns the exit code: 0 when clean, 1 with a finding, and 2 when the check cannot run.
50
+ export function runUncommittedCommand(): number {
51
+ const cwd = process.cwd();
52
+
53
+ try {
54
+ const files = readBranchFiles({ cwd, env: process.env, runProgram, withDeleted: true });
55
+
56
+ const changed = runUncommittedCheck({
57
+ cwd,
58
+ env: process.env,
59
+ files,
60
+ runProgram,
61
+ probeFile: existsSync,
62
+ });
63
+
64
+ if (changed.length > 0) {
65
+ process.stdout.write(
66
+ `uncommitted: these files the branch changes hold a change that is not committed, so the checks would read a copy the push does not send; commit or stash it:\n${changed.join('\n')}\n`,
67
+ );
68
+ }
69
+
70
+ return changed.length === 0 ? 0 : 1;
71
+ } catch (error) {
72
+ process.stderr.write(
73
+ `uncommitted: ${error instanceof Error ? error.message : String(error)}\n`,
74
+ );
75
+
76
+ return 2;
77
+ }
78
+ }
79
+
80
+ if (import.meta.main) {
81
+ process.exitCode = runUncommittedCommand();
82
+ }