From 054fe97de67242251afb980122a87027465dbf69 Mon Sep 17 00:00:00 2001 From: Masoud Soroush Date: Thu, 20 Aug 2026 22:55:52 +0200 Subject: [PATCH 1/4] fix: batch oxfmt and git-add argv by command-line length A single spawn carried every changed path, and Windows caps the whole command line at 32767 characters - 2416 CI paths met ENAMETOOLONG well before oxfmt ran. File lists are now split by cumulative length, costed against a 30k budget after the executable, config and flags take their share, and each batch is spawned on its own: --list-different merges what the batches report, format stops at the first failing batch, and git add moves from a fixed 100 paths per call - which deep CI paths could still overrun - to the same length-based split. Fixes #1 --- src/batch.ts | 34 ++++++++++++++++++++++++++++++ src/oxfmt.ts | 38 ++++++++++++++++++++++++++------- src/scm/git.ts | 11 ++++++---- test/batch.spec.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++++ test/git.spec.ts | 16 +++++++------- test/oxfmt.spec.ts | 34 ++++++++++++++++++++++++++++++ 6 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 src/batch.ts create mode 100644 test/batch.spec.ts diff --git a/src/batch.ts b/src/batch.ts new file mode 100644 index 0000000..71fb8df --- /dev/null +++ b/src/batch.ts @@ -0,0 +1,34 @@ +/** + * Split `files` into batches that fit on one command line. + * + * Windows caps the whole line handed to CreateProcess at 32767 UTF-16 units, far below + * the megabytes POSIX allows, so the Windows figure is the budget everywhere - a few + * extra spawns on Linux cost less than platform-dependent behaviour. `reserved` is what + * the invocation spends before any file: executable, leading arguments and flags. + * + * Each path is costed at its length plus three, for the separating space and the quotes + * the spawn layer may add around a path with spaces. A single path dearer than the whole + * budget still gets a batch of its own: it cannot be split, and the spawn failing loudly + * beats this module guessing. + */ +const COMMAND_LINE_LIMIT = 30_000 + +export const batchFiles = (files: string[], reserved: number): string[][] => { + const budget = COMMAND_LINE_LIMIT - reserved + const batches: string[][] = [] + let current: string[] = [] + let spent = 0 + + for (const file of files) { + const cost = file.length + 3 + if (current.length > 0 && spent + cost > budget) { + batches.push(current) + current = [] + spent = 0 + } + current.push(file) + spent += cost + } + if (current.length > 0) batches.push(current) + return batches +} diff --git a/src/oxfmt.ts b/src/oxfmt.ts index cbcf04f..6038f5d 100644 --- a/src/oxfmt.ts +++ b/src/oxfmt.ts @@ -1,3 +1,4 @@ +import { batchFiles } from './batch' import type { CommandResult, Run } from './types' /** `command` is the argv prefix from `resolveOxfmt` - usually `[node, .../oxfmt/bin/oxfmt]`. */ @@ -13,6 +14,22 @@ const invoke = ( return run(executable, [...leading, ...configArgs, ...args], root) } +/** + * The file list split to fit one command line per call, after costing everything the + * invocation already spends: command, config and flags. A changed-file list can run to + * thousands of paths, which is more than Windows lets one spawn carry. + */ +const batches = ( + command: string[], + config: string | undefined, + flags: string[], + files: string[] +): string[][] => { + const fixed = [...command, ...(config ? ['--config', config] : []), ...flags] + const reserved = fixed.reduce((sum, arg) => sum + arg.length + 3, 0) + return batchFiles(files, reserved) +} + /** * Which of `files` oxfmt would rewrite. * @@ -20,7 +37,7 @@ const invoke = ( * failure - so the status is deliberately ignored and stdout is read either way. * * Batching here is why the tool stays fast. `pretty-quick` reads, formats and compares - * each file itself in Node; oxfmt answers for the whole set in one Rust process, applying + * each file itself in Node; oxfmt answers for a whole batch in one Rust process, applying * its own config resolution and ignore rules as it goes - so there is no `.oxfmtrc` lookup * or `.gitignore` matching to reimplement. * @@ -35,13 +52,18 @@ export const listDifferent = ( config?: string ): string[] => { if (files.length === 0) return [] - return invoke(run, command, root, ['--list-different', ...files], config) - .stdout.split('\n') - .map((line) => line.trim()) - .filter(Boolean) + return batches(command, config, ['--list-different'], files).flatMap((batch) => + invoke(run, command, root, ['--list-different', ...batch], config) + .stdout.split('\n') + .map((line) => line.trim()) + .filter(Boolean) + ) } -/** Format in place. Returns false if oxfmt reported a failure. */ +/** + * Format in place. Returns false if oxfmt reported a failure, stopping at the failing + * batch - the same stop-and-report `stageFiles` does. + */ export const format = ( run: Run, command: string[], @@ -50,5 +72,7 @@ export const format = ( config?: string ): boolean => { if (files.length === 0) return true - return invoke(run, command, root, files, config).status === 0 + return batches(command, config, [], files).every( + (batch) => invoke(run, command, root, batch, config).status === 0 + ) } diff --git a/src/scm/git.ts b/src/scm/git.ts index 8eaac55..beb2879 100644 --- a/src/scm/git.ts +++ b/src/scm/git.ts @@ -1,3 +1,4 @@ +import { batchFiles } from '../batch' import type { Run } from '../types' /** @@ -53,12 +54,14 @@ export const getFilesSince = (run: Run, root: string, revision: string): string[ * Stage in batches, and report whether every batch landed. * * A single `git add` with thousands of paths blows past the command-line length limit, - * which is far lower on Windows (~32k) than on Unix. + * which is far lower on Windows (~32k) than on Unix. Batched by length, not count: a + * hundred deeply nested CI paths overrun the limit just as surely as a thousand short + * ones. */ export const stageFiles = (run: Run, root: string, files: string[]): boolean => { - const BATCH = 100 - for (let index = 0; index < files.length; index += BATCH) { - const { status } = run('git', ['add', '--', ...files.slice(index, index + BATCH)], root) + const RESERVED = 'git add --'.length + for (const batch of batchFiles(files, RESERVED)) { + const { status } = run('git', ['add', '--', ...batch], root) if (status !== 0) return false } return true diff --git a/test/batch.spec.ts b/test/batch.spec.ts new file mode 100644 index 0000000..a5e35ba --- /dev/null +++ b/test/batch.spec.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { batchFiles } from '../src/batch' + +// The budget is 30k minus `reserved`; each path costs its length plus three. Reserving +// all but a sliver keeps the arithmetic in these tests small enough to do by eye. +const reserving = (budget: number) => 30_000 - budget + +describe('batchFiles', () => { + it('returns nothing for an empty list', () => { + expect(batchFiles([], 0)).toEqual([]) + }) + + it('keeps a list that fits in a single batch, in order', () => { + expect(batchFiles(['a.ts', 'b.ts', 'c.ts'], 0)).toEqual([['a.ts', 'b.ts', 'c.ts']]) + }) + + it('starts a new batch when the next path would overrun the budget', () => { + // Each path costs 7 + 3 = 10; a budget of 20 holds exactly two. + const files = ['aaaa.ts', 'bbbb.ts', 'cccc.ts', 'dddd.ts', 'eeee.ts'] + expect(batchFiles(files, reserving(20))).toEqual([ + ['aaaa.ts', 'bbbb.ts'], + ['cccc.ts', 'dddd.ts'], + ['eeee.ts'], + ]) + }) + + it('spends the reserved length before any file', () => { + // The same two-per-batch budget, eaten into by one more reserved character. + const files = ['aaaa.ts', 'bbbb.ts', 'cccc.ts'] + expect(batchFiles(files, reserving(19))).toEqual([['aaaa.ts'], ['bbbb.ts'], ['cccc.ts']]) + }) + + it('gives a path dearer than the whole budget a batch of its own', () => { + const huge = 'x'.repeat(50) + expect(batchFiles(['a.ts', huge, 'b.ts'], reserving(20))).toEqual([['a.ts'], [huge], ['b.ts']]) + }) + + it('splits a realistic long list under the real limit', () => { + // 2416 files of CI-sized paths, the shape of the report that motivated this module. + const files = Array.from( + { length: 2416 }, + (_, i) => `D:/Builds/agent/_work/src/deep/nested/module/file-${i}.ts` + ) + const batches = batchFiles(files, 200) + expect(batches.length).toBeGreaterThan(1) + expect(batches.flat()).toEqual(files) + for (const batch of batches) { + const spent = batch.reduce((sum, file) => sum + file.length + 3, 0) + expect(spent).toBeLessThanOrEqual(30_000 - 200) + } + }) +}) diff --git a/test/git.spec.ts b/test/git.spec.ts index d794c93..15372b4 100644 --- a/test/git.spec.ts +++ b/test/git.spec.ts @@ -52,21 +52,23 @@ describe('getSinceRevision', () => { }) describe('stageFiles', () => { - it('batches at 100 paths per call, so long lists cannot exceed the command-line limit', () => { + it('batches by command-line length, so neither many paths nor long paths overrun it', () => { const calls: string[][] = [] const run = vi.fn((_cmd: string, args: string[]) => { calls.push(args) return { stdout: '', stderr: '', status: 0 } }) as unknown as Run - const files = Array.from({ length: 250 }, (_, i) => `f${i}.ts`) + // 200 paths of ~250 characters: harmless by count, far past the limit by length. + const files = Array.from({ length: 200 }, (_, i) => `${'sub/'.repeat(60)}f${i}.ts`) expect(stageFiles(run, '/repo', files)).toBe(true) - expect(calls).toHaveLength(3) - expect(calls[0].slice(0, 2)).toEqual(['add', '--']) - expect(calls[0]).toHaveLength(102) - expect(calls[1]).toHaveLength(102) - expect(calls[2]).toHaveLength(52) + expect(calls.length).toBeGreaterThan(1) + for (const args of calls) { + expect(args.slice(0, 2)).toEqual(['add', '--']) + expect(args.join(' ').length).toBeLessThanOrEqual(30_000) + } + expect(calls.flatMap((args) => args.slice(2))).toEqual(files) }) it('stops and reports false when a batch fails', () => { diff --git a/test/oxfmt.spec.ts b/test/oxfmt.spec.ts index db7e3e9..42c66df 100644 --- a/test/oxfmt.spec.ts +++ b/test/oxfmt.spec.ts @@ -61,3 +61,37 @@ describe('format', () => { expect(format(run('', 1), ['oxfmt'], '/repo', ['a.ts'])).toBe(false) }) }) + +describe('batching', () => { + // Paths long enough that a few thousand cannot fit one command line. + const files = Array.from({ length: 3000 }, (_, i) => `src/${'deep/'.repeat(10)}file-${i}.ts`) + + it('splits a long list across several invocations and merges what they report', () => { + const calls: string[][] = [] + const spy = vi.fn((_cmd: string, args: string[]) => { + calls.push(args) + return { stdout: `${args[1]}\n`, stderr: '', status: 1 } + }) as unknown as Run + + const different = listDifferent(spy, ['oxfmt'], '/repo', files) + + expect(calls.length).toBeGreaterThan(1) + expect(calls.flatMap((args) => args.slice(1))).toEqual(files) + for (const args of calls) { + expect(args[0]).toBe('--list-different') + expect(args.join(' ').length).toBeLessThanOrEqual(30_000) + } + // One reported path per invocation, all surviving the merge. + expect(different).toEqual(calls.map((args) => args[1])) + }) + + it('formats every batch, and fails when any batch fails', () => { + const ok = vi.fn(() => ({ stdout: '', stderr: '', status: 0 })) + expect(format(ok as unknown as Run, ['oxfmt'], '/repo', files)).toBe(true) + expect(ok.mock.calls.length).toBeGreaterThan(1) + + let call = 0 + const failsSecond = vi.fn(() => ({ stdout: '', stderr: '', status: call++ === 1 ? 1 : 0 })) + expect(format(failsSecond as unknown as Run, ['oxfmt'], '/repo', files)).toBe(false) + }) +}) From 0fae67664aea6bd13bc817e336a3a8905eb3785c Mon Sep 17 00:00:00 2001 From: Masoud Soroush Date: Thu, 20 Aug 2026 22:55:53 +0200 Subject: [PATCH 2/4] feat: log errors fully in CI and write a report file to attach An unexpected error used to surface as one line - the shape of the ENAMETOOLONG report in #1, where the message was all there was to go on. Now the stack prints whenever CI is set, or locally under --verbose, and every crash also writes a report to the temp directory - version, node, platform, command, cwd, and the inspected error with its code and syscall kept and its arrays capped - so an issue can say attach the log and the log is enough. The rendering lives in errorReport.ts rather than the CLI shell, inside the enforced 100% coverage. --- src/cli.mts | 23 ++++++++++- src/errorReport.ts | 56 +++++++++++++++++++++++++++ test/errorReport.spec.ts | 84 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 src/errorReport.ts create mode 100644 test/errorReport.spec.ts diff --git a/src/cli.mts b/src/cli.mts index fbf54d3..8925ed3 100644 --- a/src/cli.mts +++ b/src/cli.mts @@ -1,6 +1,7 @@ #!/usr/bin/env node import mri from 'mri' import pc from 'picocolors' +import { writeErrorReport } from './errorReport.js' import { oxfmtQuick } from './index.js' const args = mri(process.argv.slice(2), { @@ -35,12 +36,30 @@ if (args.help) { process.exit(0) } -/** A CLI should explain what went wrong, not print a stack trace at someone. */ +/** + * Interactively, a CLI should explain what went wrong, not print a stack trace at + * someone. In CI there is no someone: the log is the whole debugging session, and the + * stack is the difference between a report that can be acted on and a bare ENAMETOOLONG. + * `--verbose` asks for the same detail locally. Either way the full report goes to a + * file, so "please attach the log" is a thing a bug template can ask for. + */ +const wantStack = args.verbose || Boolean(process.env.CI && process.env.CI !== 'false') + const run = (work: () => T): T => { try { return work() } catch (error) { - console.error(`✗ ${error instanceof Error ? error.message : String(error)}`) + if (wantStack && error instanceof Error) { + console.error(`✗ ${error.stack ?? error.message}`) + } else { + console.error(`✗ ${error instanceof Error ? error.message : String(error)}`) + console.error(pc.dim(' Rerun with --verbose for the full error.')) + } + // This file and package.json are siblings of one directory, in src and dist alike. + const report = writeErrorReport(error, new URL('../package.json', import.meta.url)) + if (report) { + console.error(pc.dim(` Report written to ${report} - attach it when filing an issue.`)) + } process.exit(1) } } diff --git a/src/errorReport.ts b/src/errorReport.ts new file mode 100644 index 0000000..d6d6366 --- /dev/null +++ b/src/errorReport.ts @@ -0,0 +1,56 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { inspect } from 'node:util' + +interface Manifest { + version?: string + bugs?: { url?: string } +} + +/** The manifest is metadata for a crash report; failing to read it must not add a crash. */ +export const readManifest = (url: URL): Manifest => { + try { + return JSON.parse(readFileSync(url, 'utf8')) as Manifest + } catch { + return {} + } +} + +/** + * An error report a user can attach to an issue as-is: what ran, where, on what, and the + * whole error. `inspect` keeps an `Error`'s extra properties (`code`, `syscall`) that + * `stack` alone drops, while the array cap stops a spawn error from dumping every one of + * the thousands of paths it carried. + */ +export const renderReport = (error: unknown, manifest: Manifest, now = new Date()): string => + [ + `oxfmt-quick error report - attach this file to an issue: ${manifest.bugs?.url ?? ''}`, + `time: ${now.toISOString()}`, + `oxfmt-quick: ${manifest.version ?? 'unknown'}`, + `node: ${process.version} on ${process.platform} ${process.arch}`, + `command: oxfmt-quick ${process.argv.slice(2).join(' ')}`, + `cwd: ${process.cwd()}`, + '', + inspect(error, { depth: 4, maxArrayLength: 20 }), + '', + ].join('\n') + +/** + * Write the report and return its path, or null when even that write fails: reporting on + * the crash should not crash. The temp directory rather than the repository, so the log + * can never end up staged in the very commit that failed. + */ +export const writeErrorReport = ( + error: unknown, + manifestUrl: URL, + directory = tmpdir() +): string | null => { + try { + const path = join(directory, 'oxfmt-quick-error.log') + writeFileSync(path, renderReport(error, readManifest(manifestUrl))) + return path + } catch { + return null + } +} diff --git a/test/errorReport.spec.ts b/test/errorReport.spec.ts new file mode 100644 index 0000000..24da390 --- /dev/null +++ b/test/errorReport.spec.ts @@ -0,0 +1,84 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { readManifest, renderReport, writeErrorReport } from '../src/errorReport' + +const temp = mkdtempSync(join(tmpdir(), 'oxfmt-quick-report-')) +afterAll(() => rmSync(temp, { recursive: true, force: true })) + +const manifestUrl = new URL('../package.json', import.meta.url) + +describe('readManifest', () => { + it('reads this repository manifest', () => { + const manifest = readManifest(manifestUrl) + expect(manifest.version).toMatch(/^\d+\.\d+\.\d+/) + expect(manifest.bugs?.url).toContain('github.com') + }) + + it('returns an empty manifest rather than throwing when the file is unreadable', () => { + expect(readManifest(new URL('file:///nowhere/package.json'))).toEqual({}) + }) +}) + +describe('renderReport', () => { + it('records what ran, where, and on what', () => { + const now = new Date('2026-08-20T00:00:00Z') + const report = renderReport(new Error('boom'), { version: '9.9.9' }, now) + expect(report).toContain('time: 2026-08-20T00:00:00.000Z') + expect(report).toContain('oxfmt-quick: 9.9.9') + expect(report).toContain(`node: ${process.version} on ${process.platform} ${process.arch}`) + expect(report).toContain(`cwd: ${process.cwd()}`) + expect(report).toContain('command: oxfmt-quick ') + expect(report).toContain('Error: boom') + }) + + it('keeps the error properties a bare stack drops', () => { + const error = Object.assign(new Error('spawnSync node ENAMETOOLONG'), { + code: 'ENAMETOOLONG', + syscall: 'spawnSync node', + }) + const report = renderReport(error, {}) + expect(report).toContain("code: 'ENAMETOOLONG'") + expect(report).toContain("syscall: 'spawnSync node'") + }) + + it('caps arrays, so a spawn error does not dump thousands of paths', () => { + const error = Object.assign(new Error('too long'), { + spawnargs: Array.from({ length: 2416 }, (_, i) => `file-${i}.ts`), + }) + const report = renderReport(error, {}) + expect(report).toContain('file-19.ts') + expect(report).not.toContain('file-20.ts') + expect(report).toContain('2396 more items') + }) + + it('renders a non-Error throw and an empty manifest without pretending', () => { + const report = renderReport('just a string', {}) + expect(report).toContain("'just a string'") + expect(report).toContain('oxfmt-quick: unknown') + expect(report).toContain('attach this file to an issue: \n') + }) +}) + +describe('writeErrorReport', () => { + it('writes the report and returns its path', () => { + const path = writeErrorReport(new Error('boom'), manifestUrl, temp) + expect(path).toBe(join(temp, 'oxfmt-quick-error.log')) + const written = readFileSync(path as string, 'utf8') + expect(written).toContain('Error: boom') + expect(written).toContain('attach this file to an issue: https://github.com') + }) + + it('defaults to the temp directory, away from anything a commit could stage', () => { + const path = writeErrorReport(new Error('boom'), manifestUrl) + expect(path).toBe(join(tmpdir(), 'oxfmt-quick-error.log')) + }) + + it('returns null rather than crashing the crash handler when the write fails', () => { + // A file where the directory should be makes the write fail on every platform. + const blocked = join(temp, 'not-a-directory') + writeFileSync(blocked, '') + expect(writeErrorReport(new Error('boom'), manifestUrl, blocked)).toBeNull() + }) +}) From 541d07a05c32823ba07a2f6e7bafa624f65158ea Mon Sep 17 00:00:00 2001 From: Masoud Soroush Date: Thu, 20 Aug 2026 22:55:53 +0200 Subject: [PATCH 3/4] chore: release 1.0.1 --- package.json | 2 +- release-notes/1.0.1.md | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 release-notes/1.0.1.md diff --git a/package.json b/package.json index 014d691..9762279 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oxfmt-quick", - "version": "1.0.0", + "version": "1.0.1", "description": "Run oxfmt on your changed files, and re-stage them. A pretty-quick for the oxc formatter.", "keywords": [ "formatter", diff --git a/release-notes/1.0.1.md b/release-notes/1.0.1.md new file mode 100644 index 0000000..baaba05 --- /dev/null +++ b/release-notes/1.0.1.md @@ -0,0 +1,32 @@ +## oxfmt-quick@1.0.1 + +Survives repositories with thousands of changed files, and leaves something useful behind +when it fails. + +### Fixes + +- **`ENAMETOOLONG` with a long changed-file list is gone** ([#1]). Every oxfmt invocation + carried the whole file list in one spawn, and Windows caps a command line at 32767 + characters - 2416 CI paths hit the limit before oxfmt ran. File lists are now split into + batches by cumulative command-line length and spawned one batch at a time: + `--list-different` merges what the batches report, formatting stops at the first failing + batch, and re-staging - already batched, but by a fixed count that deep CI paths could + still overrun - moves to the same length-based split. Thanks [@lukpsaxo] for the report. + +### Changes + +- **Failures explain themselves in CI.** When `CI` is set, or locally under `--verbose`, + an unexpected error prints its full stack instead of the one-line message - which is all + the report in [#1] had to go on. +- **Every crash writes a report file to attach.** The temp directory gets + `oxfmt-quick-error.log` with the version, Node and platform, the exact command, the + working directory, and the whole error - including the `code` and `syscall` a bare stack + drops, with long argument lists capped so a spawn error stays readable. The CLI prints + the path; filing an issue is attaching that file. + +### Packaging + +No dependency, engine or export changes. `oxfmt` remains a peer dependency at `>=0.60.0`. + +[#1]: https://github.com/soroush-tech/oxfmt-quick/issues/1 +[@lukpsaxo]: https://github.com/lukpsaxo From f456ff2e34c0bdfbc95311f337bfc72ed4767a7e Mon Sep 17 00:00:00 2001 From: Masoud Soroush Date: Thu, 20 Aug 2026 22:55:54 +0200 Subject: [PATCH 4/4] chore: guard commits locally with husky hooks pre-commit fixes and re-stages what is staged; commit-msg fixes the message itself. CI keeps the range check for what hooks never see. --- .husky/commit-msg | 1 + .husky/pre-commit | 1 + package.json | 4 +++- pnpm-lock.yaml | 10 ++++++++++ 4 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 .husky/commit-msg create mode 100644 .husky/pre-commit diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..6b9a6a4 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +pnpm exec ai-watermark-guard --message "$1" --fix diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..b0e4dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm exec ai-watermark-guard --staged --fix diff --git a/package.json b/package.json index 9762279..26d5463 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,8 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:coverage": "vitest run --coverage", - "prepublishOnly": "pnpm build" + "prepublishOnly": "pnpm build", + "prepare": "husky" }, "dependencies": { "mri": "^1.2.0", @@ -77,6 +78,7 @@ "@types/node": "^26.2.0", "@vitest/coverage-v8": "^4.1.10", "ai-watermark-guard": "^0.2.0", + "husky": "^9.1.7", "oxfmt": "^0.63.0", "oxlint": "^1.78.0", "tsdown": "^0.22.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1adb9f4..ce74b16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: ai-watermark-guard: specifier: ^0.2.0 version: 0.2.0 + husky: + specifier: ^9.1.7 + version: 9.1.7 oxfmt: specifier: ^0.63.0 version: 0.63.0 @@ -810,6 +813,11 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + import-without-cache@0.4.0: resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} engines: {node: ^22.18.0 || >=24.0.0} @@ -1658,6 +1666,8 @@ snapshots: html-escaper@2.0.2: {} + husky@9.1.7: {} + import-without-cache@0.4.0: {} istanbul-lib-coverage@3.2.2: {}