diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..16e1822 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - run: npm install + - run: npm run lint + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - run: npm install + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79b7747 --- /dev/null +++ b/.gitignore @@ -0,0 +1,168 @@ +# Dependency directories +node_modules/ +jspm_packages/ +bun.lockb +node_modules.bun/ + +# Deno +.deno/ +deno.lock + +# Build outputs +dist/ +build/ +.next/ +.nuxt/ +.output/ +.cache/ +out/ + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc documentation +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +public/ + +# vue-cli dist +dist/ + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Tern JS port file +.tern-port + +# Stores VS Code state +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/code-actions.json + +# IntelliJ IDEA +.idea/ + +.crush +.obsidian + +# macOS +.DS_Store +.AppleDouble +.LSOverride +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created by macOS +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# Other Editors +*.swp +*.swo +*~ diff --git a/API_REFERENCE.md b/API_REFERENCE.md new file mode 100644 index 0000000..1992914 --- /dev/null +++ b/API_REFERENCE.md @@ -0,0 +1,94 @@ +# API Reference + +This file catalogs every public export from `@git-stunts/trailer-codec` so you can confidently import, configure, and extend the codec. + +## Encoding & decoding helpers + +### `decodeMessage(message: string)` +- Deprecated convenience wrapper around `new TrailerCodec().decode(message)`. Use `TrailerCodec` instances instead. (to be removed in v3.0) +- Input: a raw commit payload (title, optional body, trailers) as a string. +- Output: `{ title: string, body: string, trailers: Record }` where `body` is trimmed via `formatBodySegment` (see below) and trailer keys are normalized to lowercase. +- Throws `TrailerCodecError` subclasses (e.g., `TrailerNoSeparatorError`, `TrailerValueInvalidError`, or `CommitMessageInvalidError`) for invalid titles, missing blank lines, oversized messages, or malformed trailers. + +### `encodeMessage({ title: string, body?: string, trailers?: Record })` +-- Deprecated convenience wrapper around `new TrailerCodec().encode(payload)`. Use `TrailerCodec` instances instead. (to be removed in v3.0) +- Builds a `GitCommitMessage` under the hood and returns the canonical string. Trailers are converted from plain objects to `GitTrailer` instances via the default factory (see `GitTrailer` below). + +### `formatBodySegment(body?: string, { keepTrailingNewline = false })` +- Shared helper for `decodeMessage` to trim whitespace while optionally keeping a trailing newline when you plan to write the body back into a template. + +### `createMessageHelpers({ service, bodyFormatOptions } = {})` +- Returns `{ decodeMessage, encodeMessage }` bound to the injected `TrailerCodecService` instance; a new service is created when none is provided. +- Supports `bodyFormatOptions` (forwarded to `formatBodySegment`) and is useful for advanced/test wiring. + +### `createDefaultTrailerCodec({ bodyFormatOptions } = {})` +- Creates a new `TrailerCodecService`, builds a `TrailerCodec`, and returns it so you can call `encodeMessage()`/`decodeMessage()` without manually wiring services. + +### `TrailerCodec` +- Constructor opts: `{ service = new TrailerCodecService(), bodyFormatOptions }`. +- Exposes `decodeMessage(input)`/`decode(input)` and `encodeMessage(payload)`/`encode(payload)` methods that delegate to `createMessageHelpers()`. +- The `decode()` and `encode()` methods are convenience aliases added in v2.1.0. + +### `createConfiguredCodec({ keyPattern, keyMaxLength, parserOptions, formatters, bodyFormatOptions } = {})` +- Creates a schema bundle via `createGitTrailerSchemaBundle`, a `TrailerParser`, and a `TrailerCodecService`, then exposes `{ service, helpers, decodeMessage, encodeMessage }`. +- Use this when you need custom trailer patterns, parser tweaks, formatter hooks, or tightly controlled key lengths. + +## Domain model exports + +### `GitCommitMessage` +```ts +new GitCommitMessage( + payload: { title: string; body?: string; trailers?: GitTrailerInput[] }, + options?: { trailerSchema?: ReturnType['schema']; formatters?: { titleFormatter?: (value: string) => string; bodyFormatter?: (value: string) => string } } +); +``` +- Validates via `GitCommitMessageSchema`, normalizes title/body with optional formatters, and converts `trailers` to `GitTrailer` instances. +- `toString()` returns a Git-style commit string (title, optional body, blank line, trailers). + +### `GitTrailer` +- Accepts `(key: string, value: string, schema = GitTrailerSchema)`. +- Validates using `GitTrailerSchema` and normalizes the key to lowercase and the value to a trimmed string. +- Throws `TrailerInvalidError` or `TrailerValueInvalidError` when the provided key/value fail schema validation. + +## Services & parsers + +### `TrailerCodecService` +- Core decode/encode logic; see `docs/SERVICE.md` for how the pipeline is wired. +- Constructor options: + - `schemaBundle`: result of `createGitTrailerSchemaBundle({ keyPattern, keyMaxLength })` (defaults: `keyPattern` = `[A-Za-z0-9_-]+`, `keyMaxLength` = `100`, see the schema section below). + - `trailerFactory`: function that instantiates trailers (defaults to `GitTrailer`). + - `parser`: instance of `TrailerParser`. + - `messageNormalizer`, `titleExtractor`, `bodyComposer`: helper classes that normalize lines, extract the title, and compose the body. + - `formatters`: optional `{ titleFormatter, bodyFormatter }` that run before serialization. +- `decode(message)` enforces message size, normalizes lines, extracts the title, splits body/trailers with `TrailerParser`, composes the body, builds trailers with `trailerFactory`, and constructs a `GitCommitMessage`. + - `encode(messageEntity)` accepts either a `GitCommitMessage` instance or a plain payload object, validates it against `GitCommitMessageSchema`, and returns the canonical commit string produced by the entity. + +### `TrailerParser` +- Constructor takes `{ keyPattern = TRAILER_KEY_RAW_PATTERN_STRING }`. +- `split(lines)` finds where the trailer block begins (walks backward, validates the blank-line separator) and returns `{ bodyLines, trailerLines }`. +- Used internally by `TrailerCodecService` and is injectable for custom parsing strategies. + +## Schemas & constants + +### `createGitTrailerSchemaBundle({ keyPattern, keyMaxLength } = {})` +- Returns `{ schema, keyPattern, keyRegex }` with the schema used by `GitTrailer` and `GitCommitMessage`. +- Default `keyPattern` is `[A-Za-z0-9_-]+` and `keyMaxLength` defaults to `100`. + +### `TRAILER_KEY_RAW_PATTERN_STRING` / `TRAILER_KEY_REGEX` +- Exported from the default schema bundle; use them to keep custom parsers aligned with validation rules. + +## Errors + +### `TrailerCodecError` +- Base error type used throughout the codec (`index.js` re-exports it). +- Signature: `(message: string, meta: Record = {})`. + +### Validation error subclasses + +| Error | Thrown by | Meaning | +| --- | --- | --- | +| `TrailerTooLargeError` | `MessageNormalizer.guardMessageSize` (called by `TrailerCodecService.decode` and the exported `decodeMessage`) | Message exceeds the 5 MB guard in `MessageNormalizer`. | +| `TrailerNoSeparatorError` | `TrailerParser.split` / `TrailerCodecService.decode` when the blank-line guard fails | A trailer block was found without a blank line separating it from the body (see `TrailerParser`). | +| `TrailerValueInvalidError` | `GitTrailer` via `GitTrailerSchema.parse` when constructing trailers | A trailer value violated the `GitTrailerSchema` (e.g., contained `\n`). | +| `TrailerInvalidError` | `GitTrailer` via `GitTrailerSchema.parse` when constructing trailers | Trailer key or value failed validation (`GitTrailerSchema`). | +| `CommitMessageInvalidError` | `GitCommitMessage` via `GitCommitMessageSchema.parse` (triggered by `TrailerCodecService.decode` or `encode`) | The `GitCommitMessageSchema` rejected the title/body/trailers combination. | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..363b188 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,40 @@ +# Architecture: @git-stunts/trailer-codec + +This project adheres to **Hexagonal Architecture** (Ports and Adapters) and **Domain-Driven Design (DDD)** principles to ensure robustness, testability, and separation of concerns. + +## 🧱 Core Concepts + +### Domain Layer (`src/domain/`) +The core business logic, isolated from external frameworks or I/O. + +- **Entities**: Mutable objects with identity and lifecycle (e.g., `GitCommitMessage`). +- **Value Objects**: Immutable objects defined by their attributes (e.g., `GitTrailer`). +- **Services**: Domain logic that doesn't fit naturally into an entity (e.g., `TrailerCodecService` for parsing/serializing). +- **Errors**: Domain-specific error hierarchy (`TrailerCodecError` plus concrete subclasses such as `TrailerNoSeparatorError` and `TrailerValueInvalidError`). +- **Schemas**: Zod schemas for validation of domain objects. + +### Ports Layer (`src/ports/`) +*Currently implicit.* The public API (exported via `index.js`) serves as the primary input port/facade for consumers. Since this library is primarily a data transformation tool (codec), it does not currently have complex output ports for I/O. + +## πŸ“‚ Directory Structure + +``` +src/ +β”œβ”€β”€ domain/ +β”‚ β”œβ”€β”€ entities/ # GitCommitMessage +β”‚ β”œβ”€β”€ errors/ # TrailerCodecError and validation subclasses +β”‚ β”œβ”€β”€ schemas/ # Zod schemas +β”‚ β”œβ”€β”€ services/ # TrailerCodecService +β”‚ └── value-objects/ # GitTrailer +``` + +## πŸ§ͺ Testing Strategy + +- **Unit Tests** (`test/unit/`): Comprehensive tests for entities, value objects, and services. +- **Test Doubles**: The architecture supports easy mocking of dependencies if the system grows. + +## πŸ› οΈ Design Decisions + +1. **Zod for Validation**: We use Zod for runtime schema validation but wrap it in domain-specific `TrailerCodecError` subclasses to avoid leaking implementation details. +2. **Case Normalization**: Git trailer keys are case-insensitive. We normalize them to lowercase in the `GitTrailer` Value Object to ensure consistency. +3. **Facade Pattern**: `index.js` acts as a facade, providing a simple, backward-compatible API while exposing the rich domain model for advanced users. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..62ac2a8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,80 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.1.0] - 2026-01-11 + +### Added +- **Convenience method aliases**: `TrailerCodec` now exposes `decode()`/`encode()` as shorter aliases for `decodeMessage()`/`encodeMessage()` +- **Migration guide**: Created comprehensive `docs/MIGRATION.md` covering v1.x β†’ v2.0 and v2.0 β†’ v2.1 upgrades +- **Enhanced security documentation**: Expanded `SECURITY.md` with detailed DoS protection limits and customization examples +- **Test coverage**: Added 4 new tests for convenience aliases in `test/unit/adapters/FacadeAdapter.test.js` +- **Comprehensive JSDoc**: Added detailed JSDoc comments to all 13 public exports in `index.js` + +### Changed +- **Improved error handling**: All errors in `GitCommitMessage` constructor now properly wrapped in `CommitMessageInvalidError` +- **Enhanced validation**: Added string type validation for trailer values in `normalizeTrailers()` +- **Better error types**: Duplicate trailer keys and invalid values now throw `TrailerInvalidError` with rich metadata +- **Domain-specific errors**: RegExp construction errors in `GitTrailerSchema` now throw `TrailerInvalidError` instead of generic `TypeError` +- **Reduced constructor complexity**: Refactored `GitCommitMessage`, `TrailerCodecService`, and `GitTrailer` constructors by extracting validation and error handling into private methods - all ESLint complexity warnings now resolved + +### Fixed +- API inconsistency where documentation referenced `codec.decode()` but only `codec.decodeMessage()` existed +- Missing validation for trailer value types could cause runtime crashes +- Generic Error types in facade layer breaking documented error hierarchy +- README API patterns section now mentions both method name forms +- API_REFERENCE.md typo on line 37 (extra `-` character) +- TESTING.md incorrect Vitest flags (`--runInBand` β†’ `--reporter=verbose`) +- Package.json `files` field now includes `docs/` directory and all documentation files + +### Removed +- **Unnecessary Docker configuration**: Removed `Dockerfile`, `docker-compose.yml`, and `.dockerignore` - not needed for pure domain logic library with no I/O or subprocess execution +- **Simplified CI workflow**: Tests now run directly via `npm test` without Docker overhead + +### Documentation +- Created `FIXES_APPLIED.md` documenting all changes in detail +- Updated `API_REFERENCE.md` to document new aliases +- Updated `README.md` to clarify both method names are supported +- Corrected `TESTING.md` with accurate Vitest command-line flags + +## [unreleased] + +### Planned +- Integration test suite for real Git commit scenarios +- Performance benchmarks for large messages +- Additional edge case tests (unicode, empty input, concurrent usage) + + +## [2.0.0] - 2026-01-08 + +### Added +- Hexagonal architecture refactor with pure domain layer +- Zod-based schema validation for type safety +- Facade pattern for simplified usage +- DoS protection: 5MB message size limit in `decode()` +- ReDoS protection: 100-character max length on trailer keys +- Comprehensive JSDoc documentation +- Security hardening: consistent regex validation between schema and service +- Validation rules table in README +- GitHub Actions CI workflow +- Standard open-source files: LICENSE, NOTICE, SECURITY.md, CODE_OF_CONDUCT.md + +### Changed +- Trailer keys normalized to lowercase for consistency +- `GitCommitMessage` constructor accepts array of trailers +- `TrailerCodecService.decode` now validates input size before parsing +- Strict schema typing: replaced `z.array(z.any())` with `z.array(GitTrailerSchema)` +- Exported `TRAILER_KEY_RAW_PATTERN_STRING` and `TRAILER_KEY_REGEX` constants for reuse (regex is compiled from the raw string pattern) + +### Fixed +- Regex inconsistency between schema validation and service parsing +- Missing null checks in facade layer +- Unbounded input vulnerability in decode method + +### Security +- Added input size validation to prevent memory exhaustion attacks +- Limited trailer key length to prevent ReDoS attacks +- Enforced strict regex patterns across validation boundaries diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..546dc43 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,47 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at james@flyingrobots.dev. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..73b2281 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,33 @@ +# Contributing to @git-stunts/trailer-codec + +## Development Philosophy + +This project follows **Hexagonal Architecture** and **Domain-Driven Design**. + +- **Domain Layer**: Pure business logic. No dependencies on outer layers. +- **Ports**: Interfaces (if needed) for external interaction. +- **Infrastructure**: Concrete implementations (currently minimal). + +## Testing + +We use **Vitest**. +- Run all tests: `npm test` +- Run specific test: `npx vitest run test/unit/domain/entities/GitCommitMessage.test.js` + +## Style Guide + +- Use `ESLint` and `Prettier`. +- Commit messages should follow conventional commits. +- **Red -> Green -> Refactor**: Write tests before implementing features. + +## Project Structure + +```text +src/ + domain/ + entities/ # Identity, lifecycle + value-objects/ # Immutable, attributes + services/ # Stateless logic + errors/ # Domain errors + schemas/ # Validation schemas +``` diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..96d9817 --- /dev/null +++ b/NOTICE @@ -0,0 +1,16 @@ +@git-stunts/trailer-codec +Copyright 2026 James Ross + +This product includes software developed by James Ross (james@flyingrobots.dev). + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbfba6d --- /dev/null +++ b/README.md @@ -0,0 +1,202 @@ +# @git-stunts/trailer-codec + +[![npm version](https://img.shields.io/npm/v/@git-stunts/trailer-codec.svg)](https://www.npmjs.com/package/@git-stunts/trailer-codec) +[![CI](https://github.com/git-stunts/trailer-codec/actions/workflows/ci.yml/badge.svg)](https://github.com/git-stunts/trailer-codec/actions/workflows/ci.yml) +[![license](https://img.shields.io/npm/l/@git-stunts/trailer-codec.svg)](LICENSE) + +A robust encoder/decoder for structured metadata within Git commit messages. Built with **Hexagonal Architecture** and **Domain-Driven Design (DDD)**. + +## Key Features + +- **Standard Compliant**: Follows the Git "trailer" convention (RFC 822 / Email headers) +- **DoS Protection**: Built-in 5MB message size limit to prevent attacks +- **Structured Domain**: Formalized entities and value objects for type safety +- **Zod Validation**: Schema-driven validation with helpful error messages +- **Case Normalization**: Trailer keys normalized to lowercase for consistency +- **Pure Domain Logic**: No I/O, no Git subprocess execution + +## Design Principles + +1. **Domain Purity**: Core logic independent of infrastructure +2. **Type Safety**: Value Objects ensure data validity at instantiation +3. **Immutability**: All entities are immutable +4. **Separation of Concerns**: Encoding/decoding in dedicated service + +## Prerequisites + +- **Node.js**: >= 20.0.0 + +## Installation + +```bash +npm install @git-stunts/trailer-codec +``` + +## Developer & Testing + +- **Node.js β‰₯ 20** matches the `engines` field in `package.json` and is required for Vitest/ESM support. +- `npm test` runs the Vitest suite, `npm run lint` validates the code with ESLint, and `npm run format` formats files with Prettier; all scripts target the entire repo root. +- Consult `TESTING.md` for run modes, test filters, and tips for extending the suite before submitting contributions. + +## Usage + +### Basic Encoding/Decoding + +```javascript +import { createDefaultTrailerCodec } from '@git-stunts/trailer-codec'; + +const codec = createDefaultTrailerCodec(); +const message = codec.encode({ + title: 'feat: add user authentication', + body: 'Implemented OAuth2 flow with JWT tokens.', + trailers: [ + { key: 'Signed-off-by', value: 'James Ross' }, + { key: 'Reviewed-by', value: 'Alice Smith' }, + ], +}); + +console.log(message); +// feat: add user authentication +// +// Implemented OAuth2 flow with JWT tokens. +// +// signed-off-by: James Ross +// reviewed-by: Alice Smith + +const decoded = codec.decode(message); +console.log(decoded.title); // "feat: add user authentication" +console.log(decoded.trailers); // { 'signed-off-by': 'James Ross', 'reviewed-by': 'Alice Smith' } +``` + +### API Patterns + +- **Primary entry point**: `createDefaultTrailerCodec()` returns a `TrailerCodec` wired with a fresh `TrailerCodecService`; use `.encode()`/`.decode()` (or `.encodeMessage()`/`.decodeMessage()`) to keep configuration in one place. +- **Facade**: `TrailerCodec` keeps configuration near instantiation while still leveraging `createMessageHelpers()` under the hood (pass your own service when you need control). +- **Advanced**: `createConfiguredCodec()` and direct `TrailerCodecService` usage let you swap schema bundles, parsers, formatters, or helper overrides when you need custom validation or formatting behavior. The standalone helpers `encodeMessage()`/`decodeMessage()` remain available as deprecated convenience wrappers. + +### Breaking Changes + +- `decodeMessage()` now trims trailing newlines in the version `v0.2.0+` runtime, so plain string inputs will no longer include a final `\n` unless you opt into it. +- To preserve the trailing newline you rely on (e.g., when round-tripping commit templates), either instantiate `TrailerCodec` with `bodyFormatOptions: { keepTrailingNewline: true }`, call `formatBodySegment(body, { keepTrailingNewline: true })` yourself, or pass the same option through `createConfiguredCodec`. +- See [`docs/MIGRATION.md#v020`](docs/MIGRATION.md#v020) for the full migration checklist and decoding behavior rationale. + +### Body Formatting & Facade + +`decodeMessage` now trims the decoded body by default, returning the content exactly as stored; no extra newline is appended automatically. If you still need the trailing newline (for example when writing the decoded body back into a commit template), instantiate the helpers or facade with `bodyFormatOptions: { keepTrailingNewline: true }`: + +```javascript +import TrailerCodec from '@git-stunts/trailer-codec'; + +const codec = new TrailerCodec({ bodyFormatOptions: { keepTrailingNewline: true } }); +const payload = codec.decode('Title\n\nBody\n'); +console.log(payload.body); // 'Body\n' +``` + +You can also call the exported `formatBodySegment(body, { keepTrailingNewline: true })` helper directly when you need the formatting logic elsewhere. + +```javascript +import { formatBodySegment } from '@git-stunts/trailer-codec'; + +const trimmed = formatBodySegment('Body\n', { keepTrailingNewline: true }); +console.log(trimmed); // 'Body\n' +``` + +### Advanced + +#### Configured Codec Builder + +When you need a prewired codec (custom key patterns, parser tweaks, formatter hooks), use `createConfiguredCodec({ keyPattern, keyMaxLength, parserOptions })`. It builds a schema bundle, parser, and service for you, and returns helpers so you can immediately call `decodeMessage`/`encodeMessage`: + +```javascript +import { createConfiguredCodec } from '@git-stunts/trailer-codec'; + +const { decodeMessage, encodeMessage } = createConfiguredCodec({ + keyPattern: '[A-Za-z._-]+', + keyMaxLength: 120, + parserOptions: {}, +}); + +const payload = { title: 'feat: cli docs', trailers: { 'Custom.Key': 'value' } }; +const encoded = encodeMessage(payload); +const decoded = decodeMessage(encoded); +console.log(decoded.title); // 'feat: cli docs' +``` + +#### Domain Entities + +```javascript +import { GitCommitMessage } from '@git-stunts/trailer-codec'; + +const msg = new GitCommitMessage({ + title: 'fix: resolve memory leak', + body: 'Fixed WeakMap reference cycle.', + trailers: [ + { key: 'Issue', value: 'GH-123' }, + { key: 'Signed-off-by', value: 'James Ross' } + ] +}); + +console.log(msg.toString()); +``` + +#### Public API Helpers & Configuration + +- `formatBodySegment(body, { keepTrailingNewline = false })` mirrors the helper powering `decodeMessage`, trimming whitespace while optionally preserving the trailing newline when you plan to write the body back into a template. +- `createMessageHelpers({ service, bodyFormatOptions })` returns `{ decodeMessage, encodeMessage }` bound to the provided `TrailerCodecService`; pass `bodyFormatOptions` to control whether decoded bodies keep their trailing newline. +- `TrailerCodec` wraps `createMessageHelpers()` so you can instantiate a codec class with custom `service` or `bodyFormatOptions` and still leverage the helper contract via `encode()`/`decode()`. +- `createConfiguredCodec({ keyPattern, keyMaxLength, parserOptions, formatters, bodyFormatOptions })` wires together `createGitTrailerSchemaBundle`, `TrailerParser`, `TrailerCodecService`, and the helper pair, letting you configure key validation, parser heuristics, formatting hooks, and body formatting in a single call. +- `TrailerCodecService` exposes the schema bundle, parser, trailer factory, formatter hooks, and helper utilities (`MessageNormalizer`, `extractTitle`, `composeBody`); see `docs/SERVICE.md` for a deeper explanation of how to customize each stage without touching the core service. + +## βœ… Validation Rules + +Trailer codec enforces strict validation via the concrete subclasses of `TrailerCodecError`: + +| Rule | Constraint | Thrown Error | +|------|------------|--------------| +| **Message Size** | ≀ 5MB | `TrailerTooLargeError` | +| **Title** | Must be a non-empty string | `CommitMessageInvalidError` (during entity construction) | +| **Trailer Key** | Alphanumeric, hyphens, underscores only (`/^[A-Za-z0-9_-]+$/`) and ≀ 100 characters (prevents ReDoS) | `TrailerInvalidError` | +| **Trailer Value** | Cannot contain carriage returns or line feeds and must not be empty | `TrailerValueInvalidError` | + +**Key Normalization:** All trailer keys are automatically normalized to lowercase (e.g., `Signed-Off-By` β†’ `signed-off-by`). + +**Blank-Line Guard:** Trailers must be separated from the body by a blank line; omitting the separator throws `TrailerNoSeparatorError`. + +### Validation Errors + +When `TrailerCodecService` or the exported helpers throw, they surface one of the following classes so you can recover with `instanceof` checks: + +| Error | Trigger | Suggested Fix | +| --- | --- | --- | +| `TrailerTooLargeError` | Message exceeds 5MB while `MessageNormalizer.guardMessageSize()` runs | Split the commit or remove content until the payload fits. | +| `TrailerNoSeparatorError` | Missing blank line before trailers when `TrailerParser.split()` runs | Insert the required empty line between body and trailers. | +| `TrailerValueInvalidError` | Trailer value includes newline characters or fails the schema value rules | Remove or escape newline characters before encoding. | +| `TrailerInvalidError` | Trailer key/value pair fails the schema validation (`GitTrailerSchema`) | Adjust the key/value or supply a custom schema bundle via `TrailerCodecService`. | +| `CommitMessageInvalidError` | `GitCommitMessageSchema` rejects the full payload (title/body/trailers) | Fix the invalid field or pass a conforming payload; use formatters if needed. | + +All of the above inherit from `TrailerCodecError` (`src/domain/errors/TrailerCodecError.js`) and expose `meta` for diagnostics; prefer checking the specific class instead of inspecting `code`. + +## πŸ›‘οΈ Security + +- **No Code Execution**: Pure string manipulation, no `eval()` or dynamic execution +- **DoS Protection**: Rejects messages > 5MB +- **ReDoS Prevention**: Max key length limits regex execution time +- **No Git Subprocess**: Library performs no I/O operations +- **Line Injection Guard**: Trailer values omit newline characters so no unexpected trailers can be injected + +See [SECURITY.md](SECURITY.md) for details. + +## πŸ“š Additional Documentation + +- [`docs/ADVANCED.md`](docs/ADVANCED.md) β€” Custom schema injection, validation overrides, and advanced integration patterns. +- [`docs/PARSER.md`](docs/PARSER.md) β€” Step-by-step explanation of the backward-walk parser. +- [`docs/INTEGRATION.md`](docs/INTEGRATION.md) β€” Git log scripting, streaming decoder, and Git-CMS filtering recipes. +- [`docs/SERVICE.md`](docs/SERVICE.md) β€” How `TrailerCodecService` wires schema, parser, and formatter helpers for customization. +- [`API_REFERENCE.md`](API_REFERENCE.md) β€” Complete catalog of the public exports, their inputs/outputs, and notable knobs. +- [`TESTING.md`](TESTING.md) β€” How to run/extend the Vitest, lint, and format scripts plus contributor tips. +- **Git hooks**: Run `npm run setuphooks` once per clone to point `core.hooksPath` at `scripts/`. The hook now runs just `npm run lint` and `npm run format` before each commit. + +## License + +Apache-2.0 +Copyright Β© 2026 [James Ross](https://github.com/flyingrobots) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..bb19b3b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,96 @@ +# Security Model + +@git-stunts/trailer-codec is a pure domain-layer library for encoding and decoding Git commit message trailers. It performs no I/O and spawns no subprocesses. + +## πŸ›‘οΈ Design Philosophy + +This library treats commit messages as **untrusted input** and validates them strictly before parsing. + +## βœ… Validation Strategy + +- **Zod Schemas**: All inputs are validated through Zod schemas before entity instantiation +- **No Code Injection**: The library performs pure string manipulation with no `eval()` or dynamic code execution +- **Immutable Entities**: All domain entities are immutable; operations return new instances + +## πŸ›‘οΈ DoS Protection + +This library includes multiple layers of protection against Denial of Service attacks: + +### Message Size Limit +- **Limit:** 5MB (5,242,880 bytes) per commit message +- **Enforced by:** `MessageNormalizer.guardMessageSize()` +- **Error thrown:** `TrailerTooLargeError` +- **Rationale:** Prevents memory exhaustion from maliciously large inputs + +```javascript +// Messages exceeding 5MB are rejected +try { + const huge = 'a'.repeat(6 * 1024 * 1024); + service.decode(huge); +} catch (error) { + console.log(error instanceof TrailerTooLargeError); // true + console.log(error.meta.messageByteLength); + console.log(error.meta.maxSize); +} +``` + +### Key Length Limit +- **Default limit:** 100 characters per trailer key +- **Configurable:** Via `createGitTrailerSchemaBundle({ keyMaxLength })` +- **Rationale:** Prevents ReDoS attacks on key validation regex + +### Pattern Length Limit +- **Limit:** 256 characters for custom key patterns +- **Enforced by:** `buildKeyRegex()` in `GitTrailerSchema.js` +- **Rationale:** Limits regex complexity + +### Quantifier Limit +- **Limit:** 16 quantifiers (`*`, `+`, `{n,m}`) per pattern +- **Enforced by:** Pattern validation in `GitTrailerSchema.js` +- **Rationale:** Prevents catastrophic backtracking (ReDoS) + +### Line Break Protection +- **Constraint:** Trailer values cannot contain `\r` or `\n` characters +- **Error thrown:** `TrailerValueInvalidError` +- **Rationale:** Prevents trailer injection attacks + +```javascript +// This will throw TrailerValueInvalidError +new GitTrailer('key', 'value\ninjected: malicious'); +``` + +## πŸ”§ Customizing Security Limits + +Advanced users can customize limits (use with caution): + +```javascript +import { TrailerCodecService, MessageNormalizer, createGitTrailerSchemaBundle } from '@git-stunts/trailer-codec'; + +// Custom message size limit (10MB) +const normalizer = new MessageNormalizer({ + maxMessageSize: 10 * 1024 * 1024 +}); + +// Custom key length limit (120 chars) +const schemaBundle = createGitTrailerSchemaBundle({ + keyMaxLength: 120 +}); + +const service = new TrailerCodecService({ + messageNormalizer: normalizer, + schemaBundle: schemaBundle +}); +``` + +**Warning:** Increasing limits may expose your application to DoS attacks. Only adjust if you have a specific use case and understand the risks. + +## 🚫 What This Library Does NOT Do + +- **No Git Execution**: This library does not spawn Git processes +- **No File System Access**: Pure in-memory operations only +- **No Network Access**: This library makes no runtime network calls +- **Minimal Direct Dependencies**: Zod is the sole direct external dependency; any transitive dependencies introduced by Zod are inherited and should be audited separately + +## 🐞 Reporting a Vulnerability + +If you discover a security vulnerability, please send an e-mail to james@flyingrobots.dev. diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..28165ec --- /dev/null +++ b/TESTING.md @@ -0,0 +1,29 @@ +# Testing & Developer Validation + +This file describes how to exercise the validation, lint, and format tooling that backs `@git-stunts/trailer-codec` so you can ship documentation or code changes with confidence. + +## Prerequisites + +- Node.js **20.x** (per `package.json` `engines`) so Vitest, ESLint, and the ESM modules run without warnings. +- Run `npm install` once after cloning to populate `node_modules`. + +## Running the suites + +| Script | Description | +| --- | --- | +| `npm test` | Runs **Vitest** over `test/unit`. Pass additional arguments (e.g., `npm test -- test/unit/domain/services/TrailerCodecService.test.js`) to limit which files execute. Append `--reporter=verbose` for detailed output or `--no-color` for colorless logs. | +| `npm run lint` | Executes **ESLint** across the entire repository. Fix any reported issues locally. | +| `npm run format` | Runs **Prettier** with the configured settings to keep formatting consistent. Commit the formatted files or run with `--check` before publishing documentation. | + +Use `npm test -- --watch` to run Vitest in watch mode while you iterate. + +## Extending tests + +- Add unit files under `test/unit/` following the existing structure (mirrors `src/`). +- Mock dependencies with Vitest helpers (`vi.fn`, `vi.mock`) when you do not need to exercise the real parser/service. +- Snapshot tests (for serialized codec outputs or CLI/help text) verify that binary/JSON/text outputs remain stable. Regenerate them only after an intentional, reviewed change: run `npm test -- -u` locally, inspect the diff carefully, include the updated snapshots in the same PR with a clear rationale, and rely on CI to flag any unexpected snapshot drift before the merge. + +## Troubleshooting + +- If the suite fails due to missing modules, delete `node_modules` and rerun `npm install`. +- ESLint/Prettier share the `eslint.config.js`/`.prettierrc` configurations; run `npm run lint` first to enforce syntax, then `npm run format` to correct formatting drift. diff --git a/docs/ADVANCED.md b/docs/ADVANCED.md new file mode 100644 index 0000000..55e2af4 --- /dev/null +++ b/docs/ADVANCED.md @@ -0,0 +1,63 @@ +# Advanced Usage & Customization + +`@git-stunts/trailer-codec` is built on hexagonal principles, so every core service is injectable. You can override the validation rules or trailer construction without forking the library. + +## Custom Validation Rules + +```javascript +import { createGitTrailerSchemaBundle, TRAILER_KEY_RAW_PATTERN_STRING, TrailerCodecService, GitTrailer } from '@git-stunts/trailer-codec'; + +// Allow alphanumeric, underscores, hyphens, and dots +const customBundle = createGitTrailerSchemaBundle({ + keyPattern: '[A-Za-z0-9_.-]+', + keyMaxLength: 120, +}); + +const service = new TrailerCodecService({ schemaBundle: customBundle }); +const encoded = service.encode({ + title: 'Custom API', + trailers: [{ key: 'Namespace.Subfield', value: 'demo' }] +}); +``` + +## Custom Trailer Factories + +You can also replace how `GitTrailer` instances are created, which is useful for testing or adding metadata: + +```javascript +const trackedFactory = (key, value, schema) => { + const trailer = new GitTrailer(key, value, schema); + console.log('Custom trailer created', trailer); + return trailer; +}; + +const service = new TrailerCodecService({ + trailerFactory: trackedFactory +}); +``` + +## API Helpers + +Most integrations just need `encodeMessage` and `decodeMessage`. These helpers reuse a shared service instance and return/accept plain objects, making one-line usage simple: + +```javascript +import { encodeMessage, decodeMessage } from '@git-stunts/trailer-codec'; + +const data = decodeMessage(encodeMessage({ title: 'hello', trailers: { Foo: 'Bar' } })); +``` + +Use this file as a reference when you need to extend validation rules without touching the core domain logic. + +## Custom Parser Strategies + +```javascript +import { TrailerCodecService, TrailerParser, GitTrailer } from '@git-stunts/trailer-codec'; + +const parser = new TrailerParser({ keyPattern: '[A-Za-z0-9.-]+' }); +const service = new TrailerCodecService({ + parser, + trailerFactory: (key, value, schema) => new GitTrailer(key, value, schema) +}); +``` + +Pass your parser into `TrailerCodecService` so custom parsing strategies replace `_findTrailerStartIndex` and `_validateTrailerSeparation` without subclassing the service. diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md new file mode 100644 index 0000000..df4075f --- /dev/null +++ b/docs/INTEGRATION.md @@ -0,0 +1,106 @@ +# Integration Guide + +`decodeMessage()` returns `{ title: string, body: string, trailers: Record }` (see `API_REFERENCE.md#encoding--decoding-helpers`). `.title` is the first commit line, `.body` trims leading/trailing blanks to provide the content between title and trailers, and `.trailers` is an object of normalized lowercase keys (empty when no trailers exist). `formatBodySegment(segment)` expects a string and returns the trimmed segment, optionally keeping a trailing newline when `keepTrailingNewline: true`. `decodeMessage()` throws validation subclasses such as `TrailerNoSeparatorError`, `TrailerValueInvalidError`, or `CommitMessageInvalidError` instead of returning an error object, so callers should wrap calls in try/catch if they want to handle failures gracefully. + +Blend `@git-stunts/trailer-codec` with Git history and tooling to treat commit trailers as structured metadata. + +## Decode trailers from `git log --format=%B` + +1. Capture the raw commit body (title+body+trailers) with: + + ```bash + git log --format=%B + ``` + +2. Pipe each commit body into a Node script that reuses `decodeMessage`: + + ```bash + git log --format=%x00%B | node scripts/processCommits.js + ``` + +3. Example `scripts/processCommits.js`: + + ```javascript + import { decodeMessage } from '@git-stunts/trailer-codec'; + + process.stdin.setEncoding('utf8'); + let buffer = ''; + + process.stdin.on('data', (chunk) => { + buffer += chunk; + }); + + process.stdin.on('end', () => { + const commits = buffer.split('\0').filter(Boolean); + for (const commit of commits) { + const decoded = decodeMessage(commit); + console.log({ + title: decoded.title, + trailers: decoded.trailers, + }); + } + }); + ``` + +## Node / Bash loop over `git log --pretty=raw` + +1. Use `git log --pretty=raw` to include trailer lines reliably: + + ```bash + git log --pretty=raw | node scripts/logProcessor.js + ``` + +2. Example `scripts/logProcessor.js`: + + ```javascript + import { decodeMessage, formatBodySegment } from '@git-stunts/trailer-codec'; + import readline from 'node:readline'; + + const reader = readline.createInterface({ + input: process.stdin, + }); + + let commitLines = []; + + reader.on('line', (line) => { + if (line.startsWith('commit ')) { + processCommit(commitLines.join('\n')); + commitLines = []; + } + commitLines.push(line); + }); + + reader.on('close', () => { + processCommit(commitLines.join('\n')); + }); + + function processCommit(raw) { + const body = raw.split('\n\n', 2)[1] ?? ''; + if (!body.trim()) return; + const decoded = decodeMessage(body); + console.log(formatBodySegment(decoded.body)); + } + ``` + +## Git-CMS example: filtering published posts + +```javascript +import { decodeMessage } from '@git-stunts/trailer-codec'; +import { execSync } from 'child_process'; + +const log = execSync('git log --format=%x00%B').toString(); +const commits = log.split('\0').filter(Boolean); + +const posts = commits + .map((commit) => decodeMessage(commit)) + .filter((decoded) => decoded.trailers.status === 'published') + .map((decoded) => ({ + title: decoded.title, + slug: decoded.trailers.slug, + author: decoded.trailers['signed-off-by'], + })); + +console.table(posts); +``` + +For large repos, you can replace `execSync` with streaming `spawn`/`readline` to avoid buffering the whole log. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 0000000..2a7251e --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,278 @@ +# Migration Guide + +This guide helps you upgrade between major versions of `@git-stunts/trailer-codec`. + +--- + +## v2.0.0 β†’ v2.1.0 (Current) + +### New Features + +#### Convenience Aliases +`TrailerCodec` now provides shorter method names: +```javascript +const codec = new TrailerCodec({ service }); + +// Both work identically: +codec.decode(message); // βœ… New alias +codec.decodeMessage(message); // βœ… Still supported + +codec.encode(payload); // βœ… New alias +codec.encodeMessage(payload); // βœ… Still supported +``` + +**Migration:** No action required. Both forms work. + +#### Enhanced Error Handling +All errors are now properly wrapped in domain-specific error classes: +- `TrailerInvalidError` for duplicate keys and invalid values +- `CommitMessageInvalidError` wraps all construction errors +- Better error metadata for debugging + +**Migration:** If you were catching generic `Error`, update to catch specific error types: +```javascript +// Before +try { + const msg = service.decode(input); +} catch (error) { + if (error.message.includes('Duplicate')) { + // handle + } +} + +// After +import { TrailerInvalidError } from '@git-stunts/trailer-codec'; +try { + const msg = service.decode(input); +} catch (error) { + if (error instanceof TrailerInvalidError) { + console.log(error.meta); // Access rich metadata + } +} +``` + +--- + +## v1.x β†’ v2.0.0 + +### Breaking Changes + +#### 1. Body Trimming Behavior (#v020) + +**What changed:** `decodeMessage()` now trims trailing newlines by default. + +```javascript +// v1.x behavior +const result = decodeMessage('Title\n\nBody\n'); +result.body; // 'Body\n' (kept newline) + +// v2.0.0+ behavior +const result = decodeMessage('Title\n\nBody\n'); +result.body; // 'Body' (trimmed) +``` + +**Why:** Consistency with how commit messages are typically stored and displayed. + +**Migration options:** + +**Option 1:** Use `keepTrailingNewline` if you need the old behavior +```javascript +import { TrailerCodec } from '@git-stunts/trailer-codec'; + +const codec = new TrailerCodec({ + service, + bodyFormatOptions: { keepTrailingNewline: true } +}); + +const result = codec.decode('Title\n\nBody\n'); +result.body; // 'Body\n' +``` + +**Option 2:** Use `formatBodySegment` helper directly +```javascript +import { formatBodySegment } from '@git-stunts/trailer-codec'; + +const trimmed = formatBodySegment('Body\n', { keepTrailingNewline: true }); +console.log(trimmed); // 'Body\n' +``` + +**Option 3:** Add newline manually when needed +```javascript +const result = codec.decode(message); +const bodyWithNewline = result.body ? `${result.body}\n` : ''; +``` + +#### 2. Hexagonal Architecture Refactor + +**What changed:** Internal structure reorganized around DDD principles. + +**Impact:** If you were importing internal paths (not recommended), update to public API: + +```javascript +// ❌ Before (internal imports) +import GitCommitMessage from '@git-stunts/trailer-codec/src/domain/entities/GitCommitMessage'; + +// βœ… After (public API) +import { GitCommitMessage } from '@git-stunts/trailer-codec'; +``` + +**Public API exports:** +- `GitCommitMessage` - Domain entity +- `GitTrailer` - Value object +- `TrailerCodecService` - Core service +- `TrailerCodec` - Facade class +- `TrailerParser` - Parser service +- `createDefaultTrailerCodec()` - Factory +- `createConfiguredCodec()` - Advanced factory +- `createMessageHelpers()` - Helper factory +- Error classes and schemas + +#### 3. Schema-Based Validation (Zod v4) + +**What changed:** All validation now uses Zod schemas. + +**Impact:** Validation errors have more structure: + +```javascript +// v1.x +catch (error) { + console.log(error.message); // Generic string +} + +// v2.0.0+ +catch (error) { + if (error instanceof TrailerInvalidError) { + console.log(error.meta.issues); // Structured validation issues + console.log(error.meta.key); // Which trailer failed + } +} +``` + +#### 4. Trailer Key Normalization + +**What changed:** Trailer keys are automatically normalized to lowercase. + +```javascript +// v1.x +const trailer = new GitTrailer('Signed-Off-By', 'Alice'); +trailer.key; // 'Signed-Off-By' (preserved case) + +// v2.0.0+ +const trailer = new GitTrailer('Signed-Off-By', 'Alice'); +trailer.key; // 'signed-off-by' (normalized) +``` + +**Why:** Git trailer keys are case-insensitive by convention. + +**Migration:** Update code that expects specific casing: +```javascript +// Before +if (trailers['Signed-Off-By']) { } + +// After +if (trailers['signed-off-by']) { } +``` + +#### 5. Security Enhancements + +**Added:** DoS protections enabled by default: +- 5MB message size limit +- 100-character key length limit (configurable) +- 256-character regex pattern limit +- 16-quantifier ReDoS protection + +**Impact:** Messages exceeding limits now throw `TrailerTooLargeError`. + +```javascript +// v2.0.0+ will throw for > 5MB messages +try { + const huge = 'a'.repeat(6 * 1024 * 1024); + service.decode(huge); +} catch (error) { + if (error instanceof TrailerTooLargeError) { + console.log(error.meta.messageByteLength); + console.log(error.meta.maxSize); + } +} +``` + +**Override limits (advanced):** +```javascript +import MessageNormalizer from '@git-stunts/trailer-codec'; + +const normalizer = new MessageNormalizer({ + maxMessageSize: 10 * 1024 * 1024 // 10MB +}); + +const service = new TrailerCodecService({ + messageNormalizer: normalizer +}); +``` + +#### 6. Constructor Signature Changes + +**GitCommitMessage:** +```javascript +// v1.x +new GitCommitMessage(title, body, trailers); + +// v2.0.0+ +new GitCommitMessage({ title, body, trailers }); +``` + +**TrailerCodec:** +```javascript +// v1.x (didn't exist) + +// v2.0.0+ +new TrailerCodec({ service, bodyFormatOptions }); +``` + +--- + +## Testing Your Migration + +### 1. Run Your Test Suite +```bash +npm test +``` + +### 2. Check for Deprecated Warnings +```bash +# Enable Node.js deprecation warnings +NODE_OPTIONS='--trace-deprecation' npm test +``` + +### 3. Validate Error Handling +Ensure you're catching the new error types: +- `TrailerCodecError` (base) +- `TrailerTooLargeError` +- `TrailerNoSeparatorError` +- `TrailerInvalidError` +- `TrailerValueInvalidError` +- `CommitMessageInvalidError` + +### 4. Check Trailer Key Usage +Search for hardcoded trailer keys with capital letters: + +--- + +## Getting Help + +If you encounter issues during migration: + +1. Check the [API Reference](../API_REFERENCE.md) +2. Review [examples in the README](../README.md#usage) +3. Open an issue on [GitHub](https://github.com/git-stunts/trailer-codec/issues) + +--- + +## Rollback + +If you need to rollback to v1.x: + +```bash +npm install @git-stunts/trailer-codec@1.x +``` + +Note: v1.x is no longer maintained. We recommend migrating to v2.x. diff --git a/docs/PARSER.md b/docs/PARSER.md new file mode 100644 index 0000000..ef8a1b9 --- /dev/null +++ b/docs/PARSER.md @@ -0,0 +1,14 @@ +# Parser Deep Dive + +The parser in `TrailerCodecService.decode()` walks **backwards** from the bottom of the commit message to efficiently locate the trailer block. This is faster than scanning each line from the top because trailers are guaranteed to live at the end of the message. + +## Steps + +1. **Normalize line endings** to `\n` and split the message. +2. **Consume the title** (the first line) and drop the optional blank line that separates it from the body. +3. **Walk backward** with `_findTrailerStartIndex` until either a non-matching line or an empty line appears; contiguous `Key: Value` patterns form the trailer block. +4. **Validate the separator**: `_validateTrailerSeparation` ensures there is a blank line before the trailers. Messages that omit the blank line now throw `TrailerNoSeparatorError`. +5. **Trim the body** without double allocations: `_trimBody` trims leading/trailing blank lines via index arithmetic and one `join`. +6. **Parse trailers** using the schema bundle’s `keyPattern`, instantiating trailers via the injected `trailerFactory`. + +The backward walk plus the blank-line guard ensure attackers cannot append arbitrary trailers to the body without the required separator. diff --git a/docs/SERVICE.md b/docs/SERVICE.md new file mode 100644 index 0000000..1f942a3 --- /dev/null +++ b/docs/SERVICE.md @@ -0,0 +1,43 @@ +# TrailerCodecService Internals + +`TrailerCodecService` is the heart of `@git-stunts/trailer-codec`. This document explains how the service decodes/encodes commit messages, what helper classes it injects, and how to customize each stage without touching the core class (`src/domain/services/TrailerCodecService.js`). + +## Constructor inputs + +| Option | Default | Purpose | +| --- | --- | --- | +| `schemaBundle` | `getDefaultTrailerSchemaBundle()` | Supplies `GitTrailerSchema`, `keyPattern`, and `keyRegex`. Pass a custom bundle from `createGitTrailerSchemaBundle()` to change validation rules. | +| `trailerFactory` | `new GitTrailer(key, value, schema)` | Creates trailer value objects. Swap this out for instrumentation or a different trailer implementation. | +| `parser` | `new TrailerParser({ keyPattern: schemaBundle.keyPattern })` | Splits body vs trailers, validates the blank-line separator, and exposes `lineRegex` (compiled from `schemaBundle.keyPattern`) so you can match each trailer line consistently. Inject a decorated parser to control detection heuristics and refer to [`docs/PARSER.md`](docs/PARSER.md) for parser internals. | +| `messageNormalizer` | `new MessageNormalizer()` | Normalizes line endings (`\r\n` β†’ `\n`) and guards against messages > 5 MB (throws `TrailerTooLargeError`). Override to adjust the max size or normalization logic. | +| `titleExtractor` | `extractTitle` | Grabs the first line as the title, trims it, and skips consecutive blank lines. Replace to support multi-line titles or custom separators. | +| `bodyComposer` | `composeBody` | Trims leading/trailing blank lines from the body while preserving user whitespace inside. Swap it when you want to preserve blank lines that occur at the edges. | +| `formatters` | `{}` | Accepts `{ titleFormatter, bodyFormatter }` functions applied before serialization. Use them to normalize casing, apply templates, or inject defaults before `encode()`. | + +TrailerParser compiles the `schemaBundle.keyPattern` into `parser.lineRegex` during construction, so each trailer line is matched with the same RegExp used for validation; refer to [`docs/PARSER.md`](docs/PARSER.md) or `TrailerParser` constructor docs for more detail when you override this behavior. + +## Decode pipeline (see `decode()` in `src/domain/services/TrailerCodecService.js`) + +1. **Empty message guard** β€” Immediately returns an empty `GitCommitMessage` when given `undefined`/`''` so downstream code receives an entity instead of `null`. +2. **Message size check** β€” `MessageNormalizer.guardMessageSize()` enforces the 5 MB limit and throws `TrailerTooLargeError` when exceeded. +3. **Line normalization** β€” `MessageNormalizer.normalizeLines()` converts `\r\n` to `\n` and splits into lines. +4. **Title extraction** β€” `extractTitle()` takes the first line, trims it, and skips all blank lines between the title and body. +5. **Split body/trailers** β€” `TrailerParser.split()` walks backward from the end of the message (using `_findTrailerStart`) to locate the trailer block and enforce the blank-line guard (`TrailerNoSeparatorError`). +6. **Compose body** β€” `composeBody()` trims blank lines from the edges but keeps inner spacing intact. +7. **Build trailers** β€” Iterates over the trailer lines, matches each against `parser.lineRegex`, and calls `trailerFactory(key, value, schemaBundle.schema)` to convert them into `GitTrailer` instances. +8. **Entity construction** β€” Returns a `GitCommitMessage` with the normalized title/body and the built trailers; `formatters` (e.g., `titleFormatter`, `bodyFormatter`) run during this step. + +## Encode pipeline + +- `encode(messageEntity)` accepts either a `GitCommitMessage` instance or a plain object. If it is not already an entity, it constructs a new `GitCommitMessage` using the same `schemaBundle` and `formatters`, ensuring validation still runs. +- `GitCommitMessage.toString()` outputs the final commit string (title, blank line, body, blank line, trailers) and trims trailing whitespace. + +## Customization points + +- **Schema bundle**: call `createGitTrailerSchemaBundle()` with `keyPattern`/`keyMaxLength` and pass it as `schemaBundle` to alter validation. +- **Parser**: supply a custom `TrailerParser` (e.g., with a `parserOptions` object) via `createConfiguredCodec`; it can override `_findTrailerStart` heuristics while still leveraging the rest of the service. +- **Trailer factory**: use `trailerFactory` to wrap `GitTrailer` creation with logging, metrics, or a different subclass. +- **Helpers**: replace `MessageNormalizer`, `extractTitle`, or `composeBody` when you need different normalization or trimming strategies (useful for non-Git commit inputs). +- **Formatters**: pass `formatters` (e.g., `{ titleFormatter: (value) => value.toUpperCase() }`) to modify the title/body before serialization. + +For a ready-to-use abstraction, prefer `createConfiguredCodec()` (which wires your custom schema, parser, and formatters) or extend `TrailerCodecService` via dependency injection rather than subclassing. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..982f96f --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,50 @@ +import js from '@eslint/js'; + +export default [ + js.configs.recommended, + { + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + process: 'readonly', + Buffer: 'readonly', + console: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + }, + }, + rules: { + complexity: ['error', 10], + 'max-depth': ['error', 3], + 'max-lines-per-function': ['error', 50], + 'max-params': ['error', 3], + 'max-nested-callbacks': ['error', 3], + 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + 'no-console': 'error', + eqeqeq: ['error', 'always'], + curly: ['error', 'all'], + 'no-eval': 'error', + 'no-implied-eval': 'error', + 'no-new-wrappers': 'error', + 'no-caller': 'error', + 'no-undef-init': 'error', + 'no-var': 'error', + 'prefer-const': 'error', + 'prefer-template': 'error', + yoda: ['error', 'never'], + 'consistent-return': 'error', + 'no-shadow': 'error', + 'no-use-before-define': ['error', { functions: false }], + 'no-lonely-if': 'error', + 'no-unneeded-ternary': 'error', + 'one-var': ['error', 'never'], + }, + }, + { + files: ['test/**/*.js'], + rules: { + 'max-lines-per-function': 'off', + }, + }, +]; diff --git a/index.js b/index.js new file mode 100644 index 0000000..359faf8 --- /dev/null +++ b/index.js @@ -0,0 +1,93 @@ +/** + * @fileoverview Trailer Codec - a robust encoder/decoder for structured metadata within Git commit messages. + * + * @module @git-stunts/trailer-codec + * @description Primary entry point re-exporting entities, services, adapters, and helpers so consumers can + * import exactly the layer they need without reaching into internal paths. + */ + +/** + * Domain entity representing a structured Git commit message. + * @see {@link ./src/domain/entities/GitCommitMessage.js} + */ +export { default as GitCommitMessage } from './src/domain/entities/GitCommitMessage.js'; + +/** + * Value object representing a Git trailer key-value pair. + * Keys are normalized to lowercase and values are trimmed. + * @see {@link ./src/domain/value-objects/GitTrailer.js} + */ +export { default as GitTrailer } from './src/domain/value-objects/GitTrailer.js'; + +/** + * Core service for encoding and decoding commit messages. + * Provides customizable validation, parsing, and formatting. + * @see {@link ./src/domain/services/TrailerCodecService.js} + */ +export { default as TrailerCodecService } from './src/domain/services/TrailerCodecService.js'; + +/** + * Base error class for all trailer codec errors. + * Includes subclasses for specific error types. + * @see {@link ./src/domain/errors/TrailerCodecError.js} + */ +export { default as TrailerCodecError } from './src/domain/errors/TrailerCodecError.js'; + +/** + * Schema factory and constants for trailer validation. + * - `createGitTrailerSchemaBundle` - Factory for custom validation schemas + * - `TRAILER_KEY_RAW_PATTERN_STRING` - Default key pattern string + * - `TRAILER_KEY_REGEX` - Compiled key validation regex + * @see {@link ./src/domain/schemas/GitTrailerSchema.js} + */ +export { createGitTrailerSchemaBundle, TRAILER_KEY_RAW_PATTERN_STRING, TRAILER_KEY_REGEX } from './src/domain/schemas/GitTrailerSchema.js'; + +/** + * Parser service for extracting trailers from commit messages. + * Uses backward-walk algorithm for efficiency. + * @see {@link ./src/domain/services/TrailerParser.js} + */ +export { default as TrailerParser } from './src/domain/services/TrailerParser.js'; + +/** + * Facade class providing convenient encode/decode methods. + * Supports both `.decode()`/`.encode()` and `.decodeMessage()`/`.encodeMessage()`. + * @see {@link ./src/adapters/FacadeAdapter.js} + */ +export { default as TrailerCodec } from './src/adapters/FacadeAdapter.js'; + +/** + * Factory for creating message helpers bound to a service. + * Returns `{ decodeMessage, encodeMessage }` functions. + * @see {@link ./src/adapters/FacadeAdapter.js} + */ +export { createMessageHelpers } from './src/adapters/FacadeAdapter.js'; + +/** + * Convenience function for decoding messages (deprecated). + * Prefer using `TrailerCodec` instances for most use cases. + * @deprecated Use TrailerCodec class instead + * @see {@link ./src/adapters/FacadeAdapter.js} + */ +export { decodeMessage } from './src/adapters/FacadeAdapter.js'; + +/** + * Convenience function for encoding messages (deprecated). + * Prefer using `TrailerCodec` instances for most use cases. + * @deprecated Use TrailerCodec class instead + * @see {@link ./src/adapters/FacadeAdapter.js} + */ +export { encodeMessage } from './src/adapters/FacadeAdapter.js'; + +/** + * Helper for formatting body segments with optional trailing newline. + * @see {@link ./src/adapters/FacadeAdapter.js} + */ +export { formatBodySegment } from './src/adapters/FacadeAdapter.js'; + +/** + * Advanced factory for creating fully configured codecs. + * Allows customizing key patterns, parsers, formatters, and options. + * @see {@link ./src/adapters/CodecBuilder.js} + */ +export { createConfiguredCodec } from './src/adapters/CodecBuilder.js'; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..65ff62a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2654 @@ +{ + "name": "@git-stunts/trailer-codec", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@git-stunts/trailer-codec", + "version": "2.0.0", + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.3.5" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "eslint": "^9.17.0", + "prettier": "^3.4.2", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "zod": "^4.3.5" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..944123e --- /dev/null +++ b/package.json @@ -0,0 +1,67 @@ +{ + "name": "@git-stunts/trailer-codec", + "version": "2.1.0", + "description": "Robust encoder/decoder for structured metadata in Git commit messages", + "type": "module", + "main": "index.js", + "exports": { + ".": "./index.js", + "./service": "./src/domain/services/TrailerCodecService.js", + "./message": "./src/domain/entities/GitCommitMessage.js", + "./trailer": "./src/domain/value-objects/GitTrailer.js", + "./errors": "./src/domain/errors/TrailerCodecError.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "test": "vitest run test/unit \"$@\"", + "lint": "eslint .", + "format": "prettier --write .", + "setuphooks": "git config core.hooksPath scripts" + }, + "author": "James Ross ", + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.3.5" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "eslint": "^9.17.0", + "prettier": "^3.4.2", + "vitest": "^3.0.0", + "@babel/parser": "^7.24.7" + }, + "files": [ + "src", + "docs", + "index.js", + "README.md", + "API_REFERENCE.md", + "ARCHITECTURE.md", + "LICENSE", + "NOTICE", + "SECURITY.md", + "CHANGELOG.md", + "TESTING.md", + "CONTRIBUTING.md", + "CODE_OF_CONDUCT.md" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/git-stunts/trailer-codec.git" + }, + "homepage": "https://github.com/git-stunts/trailer-codec#readme", + "bugs": { + "url": "https://github.com/git-stunts/trailer-codec/issues" + }, + "keywords": [ + "git", + "commit", + "trailers", + "metadata", + "git-stunts", + "hexagonal", + "ddd" + ] +} diff --git a/scripts/pre-commit.sh b/scripts/pre-commit.sh new file mode 100755 index 0000000..7599075 --- /dev/null +++ b/scripts/pre-commit.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run the fast checks we care about before committing. +npm run lint +npm run format diff --git a/src/adapters/CodecBuilder.js b/src/adapters/CodecBuilder.js new file mode 100644 index 0000000..089ad96 --- /dev/null +++ b/src/adapters/CodecBuilder.js @@ -0,0 +1,53 @@ +import TrailerCodecService from '../domain/services/TrailerCodecService.js'; +import TrailerParser from '../domain/services/TrailerParser.js'; +import { createGitTrailerSchemaBundle } from '../domain/schemas/GitTrailerSchema.js'; +import { createMessageHelpers } from './FacadeAdapter.js'; +import { validateType } from '../utils/zodValidator.js'; + +const assertObject = (value, name) => { + if (value !== undefined && (value === null || typeof value !== 'object')) { + throw new TypeError(`${name} must be an object when provided`); + } +}; + +/** + * Compose a configured codec that wires schema, parser, service, and helpers. + * + * @param {Object} [options] + * @param {string|RegExp} [options.keyPattern] Pattern or regex for trailer keys. + * @param {number} [options.keyMaxLength=100] Maximum length for trailer keys. + * @param {Object} [options.parserOptions] Passed through to `TrailerParser`. + * @param {Object} [options.formatters] Formatter hooks `{ titleFormatter, bodyFormatter }`. + * @param {Object} [options.bodyFormatOptions] Forwarded to `formatBodySegment`. + * @returns {{ + * service: TrailerCodecService, + * helpers: ReturnType, + * decodeMessage: Function, + * encodeMessage: Function + * }} + * + * @throws {Error} on schema validation failure (ZodError). + */ +export function createConfiguredCodec(options = {}) { + const validated = validateType('createConfiguredCodecOptions', options); + const { keyPattern, keyMaxLength } = validated; + const { parserOptions, formatters, bodyFormatOptions } = options; + assertObject(parserOptions, 'parserOptions'); + assertObject(formatters, 'formatters'); + assertObject(bodyFormatOptions, 'bodyFormatOptions'); + + const schemaBundle = createGitTrailerSchemaBundle({ keyPattern, keyMaxLength }); + const parser = new TrailerParser({ keyPattern: schemaBundle.keyPattern, ...parserOptions }); + const service = new TrailerCodecService({ + schemaBundle, + parser, + formatters, + }); + const helpers = createMessageHelpers({ service, bodyFormatOptions }); + return { + service, + helpers, + decodeMessage: helpers.decodeMessage, + encodeMessage: helpers.encodeMessage, + }; +} diff --git a/src/adapters/FacadeAdapter.js b/src/adapters/FacadeAdapter.js new file mode 100644 index 0000000..72da2be --- /dev/null +++ b/src/adapters/FacadeAdapter.js @@ -0,0 +1,163 @@ +import TrailerCodecService from '../domain/services/TrailerCodecService.js'; +import TrailerInvalidError from '../domain/errors/TrailerInvalidError.js'; + +function normalizeInput(input) { + if (typeof input === 'string' && input.length > 0) { + return input; + } + + throw new TypeError('normalizeInput expects a non-empty string'); +} + +function normalizeTrailers(entity) { + if (!entity || !Array.isArray(entity.trailers)) { + throw new TypeError('Invalid entity: trailers array is required'); + } + + return entity.trailers.reduce((acc, trailer) => { + if (!trailer || typeof trailer.key !== 'string' || trailer.key.trim() === '') { + throw new TypeError('Invalid trailer: non-empty string key is required'); + } + if (typeof trailer.value !== 'string') { + throw new TrailerInvalidError('Trailer value must be a string', { + key: trailer.key, + invalidValue: trailer.value, + valueType: typeof trailer.value, + }); + } + if (acc[trailer.key] !== undefined) { + throw new TrailerInvalidError(`Duplicate trailer key: "${trailer.key}"`, { + key: trailer.key, + existingValue: acc[trailer.key], + duplicateValue: trailer.value, + }); + } + acc[trailer.key] = trailer.value; + return acc; + }, {}); +} + +/** + * Trim whitespace from a commit body segment and optionally keep a trailing newline. + * @param {string} [body] + * @param {Object} [options] + * @param {boolean} [options.keepTrailingNewline=false] + * @returns {string} + */ +export function formatBodySegment(body, { keepTrailingNewline = false } = {}) { + const trimmed = (body ?? '').trim(); + if (!trimmed) { + return ''; + } + return keepTrailingNewline ? `${trimmed}\n` : trimmed; +} + +/** + * Advanced helper factory for tests or tools that need direct access to helpers. + */ +/** + * Advanced helper factory for tests or when you need to control the service instance. + * @param {Object} [options] + * @param {TrailerCodecService} [options.service] - Optional custom service (defaults to new one). + * @param {Object} [options.bodyFormatOptions] - Options forwarded to `formatBodySegment`. + * @returns {{ decodeMessage: (input: string) => { title: string, body: string, trailers: Record }, encodeMessage: (payload: { title: string, body?: string, trailers?: Record }) => string }} + */ +export function createMessageHelpers({ service = new TrailerCodecService(), bodyFormatOptions } = {}) { + function decode(input) { + const message = normalizeInput(input); + const entity = service.decode(message); + return { + title: entity.title, + body: formatBodySegment(entity.body, bodyFormatOptions), + trailers: normalizeTrailers(entity), + }; + } + + function encode({ title, body, trailers = {} }) { + const trailerArray = Object.entries(trailers).map(([key, value]) => ({ key, value })); + return service.encode({ title, body, trailers: trailerArray }); + } + + return { decodeMessage: decode, encodeMessage: encode }; +} + +/** + * TrailerCodec is the main public API. Provide a `TrailerCodecService` to reuse configuration + * and helper instances. + */ +/** + * TrailerCodec is the main public API for encode/decode through an injectable service. + */ +class TrailerCodec { + /** + * @param {{ service: TrailerCodecService, bodyFormatOptions?: Object }} options + */ + constructor({ service, bodyFormatOptions } = {}) { + if (!service) { + throw new TypeError('TrailerCodec requires a TrailerCodecService instance'); + } + this.helpers = createMessageHelpers({ service, bodyFormatOptions }); + } + + /** + * Decode a raw commit payload. + * @param {string} input + */ + decodeMessage(input) { + return this.helpers.decodeMessage(input); + } + + /** + * Encode a payload back into a commit string. + * @param {Object} payload + */ + encodeMessage(payload) { + return this.helpers.encodeMessage(payload); + } + + /** + * Convenience alias for decodeMessage. + * @param {string} input + */ + decode(input) { + return this.decodeMessage(input); + } + + /** + * Convenience alias for encodeMessage. + * @param {Object} payload + */ + encode(payload) { + return this.encodeMessage(payload); + } +} + +export default TrailerCodec; + +/** + * Construct a ready-to-use `TrailerCodec` with a fresh service instance. + * @param {Object} [options] + * @param {Object} [options.bodyFormatOptions] - Passed to helpers for body trimming. + * @returns {TrailerCodec} + */ +export function createDefaultTrailerCodec({ bodyFormatOptions } = {}) { + return new TrailerCodec({ service: new TrailerCodecService(), bodyFormatOptions }); +} + +/** + * @deprecated Use `TrailerCodec` instances for most call sites. + */ +/** + * @deprecated Use `TrailerCodec.decodeMessage` directly. + * Convenience wrapper that builds a default codec and decodes the message. + */ +export function decodeMessage(message, bodyFormatOptions) { + return createDefaultTrailerCodec({ bodyFormatOptions }).decodeMessage(message); +} + +/** + * @deprecated Use `TrailerCodec` instances for most call sites. + */ +export function encodeMessage(payload, bodyFormatOptions) { + return createDefaultTrailerCodec({ bodyFormatOptions }).encodeMessage(payload); +} diff --git a/src/domain/entities/GitCommitMessage.js b/src/domain/entities/GitCommitMessage.js new file mode 100644 index 0000000..464e4f8 --- /dev/null +++ b/src/domain/entities/GitCommitMessage.js @@ -0,0 +1,114 @@ +import { GitCommitMessageSchema } from '../schemas/GitCommitMessageSchema.js'; +import GitTrailer from '../value-objects/GitTrailer.js'; +import CommitMessageInvalidError from '../errors/CommitMessageInvalidError.js'; +import { ZodError } from 'zod'; +import { GitTrailerSchema } from '../schemas/GitTrailerSchema.js'; + +const defaultFormatter = (value) => (value ?? '').toString().trim(); + +const ensureFormatterIsFunction = (name, formatter) => { + if (formatter !== undefined && typeof formatter !== 'function') { + throw new CommitMessageInvalidError(`Formatter "${name}" must be a function`, { + formatterName: name, + formatterValue: formatter, + }); + } +}; + +/** + * Domain entity representing a structured Git commit message (title/body/trailers). + */ +export default class GitCommitMessage { + /** + * @param {{ title: string; body?: string; trailers?: Array<{ key: string; value: string } | GitTrailer> }} payload + * @param {{ + * trailerSchema?: import('../schemas/GitTrailerSchema.js').GitTrailerSchema, + * formatters?: { titleFormatter?: (value: string) => string; bodyFormatter?: (value: string) => string } + * } } [options] + */ + constructor( + { title, body = '', trailers = [] }, + { trailerSchema = GitTrailerSchema, formatters = {} } = {} + ) { + try { + GitCommitMessageSchema.parse({ title, body, trailers }); + + const { titleFormatter, bodyFormatter } = this._validateFormatters(formatters); + + this.title = titleFormatter(title); + this.body = bodyFormatter(body); + this.trailers = this._normalizeTrailers(trailers, trailerSchema); + } catch (error) { + throw this._handleConstructorError(error); + } + } + + /** + * Validates and returns formatters with defaults applied. + * @private + */ + _validateFormatters(formatters) { + const { titleFormatter = defaultFormatter, bodyFormatter = defaultFormatter } = formatters; + ensureFormatterIsFunction('titleFormatter', titleFormatter); + ensureFormatterIsFunction('bodyFormatter', bodyFormatter); + return { titleFormatter, bodyFormatter }; + } + + /** + * Converts trailer-like objects to GitTrailer instances. + * @private + */ + _normalizeTrailers(trailers, trailerSchema) { + return trailers.map((t) => + t instanceof GitTrailer ? t : new GitTrailer(t.key, t.value, trailerSchema) + ); + } + + /** + * Wraps constructor errors in CommitMessageInvalidError. + * @private + */ + _handleConstructorError(error) { + if (error instanceof ZodError) { + return new CommitMessageInvalidError( + `Invalid commit message: ${error.issues.map((i) => i.message).join(', ')}`, + { issues: error.issues } + ); + } + if (error instanceof CommitMessageInvalidError) { + return error; + } + return new CommitMessageInvalidError( + `Unexpected error during commit message construction: ${error.message}`, + { originalError: error, errorType: error.constructor.name } + ); + } + + /** + * Returns the encoded commit message string (title, blank line, body, trailers). + * @returns {string} + */ + toString() { + let message = `${this.title}\n\n`; + if (this.body) { + message += `${this.body}\n\n`; + } + + if (this.trailers.length > 0) { + message += `${this.trailers.map((t) => t.toString()).join('\n') }\n`; + } + + return `${message.trimEnd() }\n`; + } + + /** + * @returns {{ title: string; body: string; trailers: Array<{ key: string; value: string }> }} + */ + toJSON() { + return { + title: this.title, + body: this.body, + trailers: this.trailers.map((t) => t.toJSON()), + }; + } +} diff --git a/src/domain/errors/CommitMessageInvalidError.js b/src/domain/errors/CommitMessageInvalidError.js new file mode 100644 index 0000000..7f3d0d4 --- /dev/null +++ b/src/domain/errors/CommitMessageInvalidError.js @@ -0,0 +1,12 @@ +import TrailerCodecError from './TrailerCodecError.js'; + +/** Error thrown when a commit message payload fails GitCommitMessageSchema validation. */ +export default class CommitMessageInvalidError extends TrailerCodecError { + /** + * @param {string} message + * @param {Record} [meta] + */ + constructor(message, meta = {}) { + super(message, meta); + } +} diff --git a/src/domain/errors/TrailerCodecError.js b/src/domain/errors/TrailerCodecError.js new file mode 100644 index 0000000..86fb98f --- /dev/null +++ b/src/domain/errors/TrailerCodecError.js @@ -0,0 +1,15 @@ +/** + * Base error class for all Trailer Codec related errors. + */ +export default class TrailerCodecError extends Error { + /** + * @param {string} message - Human readable error message. + * @param {Object} [meta] - Additional metadata context. + */ + constructor(message, meta = {}) { + super(message); + this.name = this.constructor.name; + this.meta = meta; + Error.captureStackTrace(this, this.constructor); + } +} diff --git a/src/domain/errors/TrailerInvalidError.js b/src/domain/errors/TrailerInvalidError.js new file mode 100644 index 0000000..20d4382 --- /dev/null +++ b/src/domain/errors/TrailerInvalidError.js @@ -0,0 +1,12 @@ +import TrailerCodecError from './TrailerCodecError.js'; + +/** Error raised when a trailer key/value pair violates schema validation. */ +export default class TrailerInvalidError extends TrailerCodecError { + /** + * @param {string} message + * @param {Record} [meta] + */ + constructor(message, meta = {}) { + super(message, meta); + } +} diff --git a/src/domain/errors/TrailerNoSeparatorError.js b/src/domain/errors/TrailerNoSeparatorError.js new file mode 100644 index 0000000..222220e --- /dev/null +++ b/src/domain/errors/TrailerNoSeparatorError.js @@ -0,0 +1,12 @@ +import TrailerCodecError from './TrailerCodecError.js'; + +/** Error raised when trailers are not separated by the required blank line. */ +export default class TrailerNoSeparatorError extends TrailerCodecError { + /** + * @param {string} message + * @param {Record} [meta] + */ + constructor(message, meta = {}) { + super(message, meta); + } +} diff --git a/src/domain/errors/TrailerTooLargeError.js b/src/domain/errors/TrailerTooLargeError.js new file mode 100644 index 0000000..8c3afa3 --- /dev/null +++ b/src/domain/errors/TrailerTooLargeError.js @@ -0,0 +1,12 @@ +import TrailerCodecError from './TrailerCodecError.js'; + +/** Error raised when a message exceeds the configured byte limit. */ +export default class TrailerTooLargeError extends TrailerCodecError { + /** + * @param {string} message + * @param {Record} [meta] + */ + constructor(message, meta = {}) { + super(message, meta); + } +} diff --git a/src/domain/errors/TrailerValueInvalidError.js b/src/domain/errors/TrailerValueInvalidError.js new file mode 100644 index 0000000..35f4616 --- /dev/null +++ b/src/domain/errors/TrailerValueInvalidError.js @@ -0,0 +1,12 @@ +import TrailerCodecError from './TrailerCodecError.js'; + +/** Error raised when a trailer value does not satisfy GitTrailerSchema rules. */ +export default class TrailerValueInvalidError extends TrailerCodecError { + /** + * @param {string} message + * @param {Record} [meta] + */ + constructor(message, meta = {}) { + super(message, meta); + } +} diff --git a/src/domain/schemas/GitCommitMessageSchema.js b/src/domain/schemas/GitCommitMessageSchema.js new file mode 100644 index 0000000..932cae5 --- /dev/null +++ b/src/domain/schemas/GitCommitMessageSchema.js @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import { GitTrailerSchema } from './GitTrailerSchema.js'; + +/** + * Zod schema validating a structured Git commit message. + * + * Fields: + * - `title`: non-empty string representing the first commit line. + * - `body`: string defaulting to `''`. + * - `trailers`: array of `GitTrailer` objects, each validated by `GitTrailerSchema`. + */ +export const GitCommitMessageSchema = z.object({ + title: z.string().min(1), + body: z.string().default(''), + trailers: z.array(GitTrailerSchema), +}); diff --git a/src/domain/schemas/GitTrailerSchema.js b/src/domain/schemas/GitTrailerSchema.js new file mode 100644 index 0000000..adc588e --- /dev/null +++ b/src/domain/schemas/GitTrailerSchema.js @@ -0,0 +1,74 @@ +import { z } from 'zod'; +import TrailerInvalidError from '../errors/TrailerInvalidError.js'; + +const DEFAULT_KEY_PATTERN = '[A-Za-z0-9_\\-]+'; +const MAX_PATTERN_LENGTH = 256; +const MAX_QUANTIFIERS = 16; +const bundleCache = new Map(); + +const buildKeyRegex = (keyPattern) => { + if (keyPattern.length > MAX_PATTERN_LENGTH) { + throw new RangeError(`keyPattern exceeds max length of ${MAX_PATTERN_LENGTH}`); + } + const quantifierCount = (keyPattern.match(/(\*|\+|\{)/g) ?? []).length; + if (quantifierCount > MAX_QUANTIFIERS) { + throw new Error('keyPattern uses too many quantifiers and may be vulnerable to ReDoS'); + } + + try { + return new RegExp(`^${keyPattern}$`); + } catch (error) { + throw new TrailerInvalidError( + `Invalid regex pattern for trailer key: ${error.message}`, + { keyPattern, originalError: error.message } + ); + } +}; + +/** + * Creates a Git trailer schema bundle with customizable validation rules. + * @param {Object} options - Configuration options + * @param {string} options.keyPattern - Regex pattern string for key validation (will be anchored) + * @param {number} options.keyMaxLength - Maximum length for trailer keys + * @returns {{ schema: z.ZodObject, keyPattern: string, keyRegex: RegExp }} + */ +export function createGitTrailerSchemaBundle({ keyPattern = DEFAULT_KEY_PATTERN, keyMaxLength = 100 } = {}) { + if (typeof keyPattern !== 'string' || keyPattern.length === 0) { + throw new TypeError('keyPattern must be a non-empty string'); + } + if (!Number.isInteger(keyMaxLength) || keyMaxLength <= 0) { + throw new TypeError('keyMaxLength must be a positive integer'); + } + + const cacheKey = `${keyPattern}::${keyMaxLength}`; + if (bundleCache.has(cacheKey)) { + return bundleCache.get(cacheKey); + } + + const keyRegex = buildKeyRegex(keyPattern); + const bundle = { + schema: z.object({ + key: z + .string() + .min(1) + .max(keyMaxLength, 'Trailer key must not exceed character limit') + .regex(keyRegex, `Trailer key must match the required pattern ${keyPattern}`), + value: z + .string() + .min(1) + .regex(/^[^\r\n]+$/, 'Trailer values cannot contain line breaks'), + }), + keyPattern, + keyRegex, + }; + + bundleCache.set(cacheKey, bundle); + return bundle; +} + +const DEFAULT_SCHEMA_BUNDLE = createGitTrailerSchemaBundle(); + +export const GitTrailerSchema = DEFAULT_SCHEMA_BUNDLE.schema; +export const TRAILER_KEY_RAW_PATTERN_STRING = DEFAULT_SCHEMA_BUNDLE.keyPattern; +export const TRAILER_KEY_REGEX = DEFAULT_SCHEMA_BUNDLE.keyRegex; +export const getDefaultTrailerSchemaBundle = () => DEFAULT_SCHEMA_BUNDLE; diff --git a/src/domain/services/TrailerCodecService.js b/src/domain/services/TrailerCodecService.js new file mode 100644 index 0000000..0ad385a --- /dev/null +++ b/src/domain/services/TrailerCodecService.js @@ -0,0 +1,159 @@ +import GitCommitMessage from '../entities/GitCommitMessage.js'; +import GitTrailer from '../value-objects/GitTrailer.js'; +import { getDefaultTrailerSchemaBundle } from '../schemas/GitTrailerSchema.js'; +import TrailerParser from './TrailerParser.js'; +import MessageNormalizer from './helpers/MessageNormalizer.js'; +import { extractTitle } from './helpers/TitleExtractor.js'; +import { composeBody } from './helpers/BodyComposer.js'; + +const defaultTrailerFactory = (key, value, schema) => new GitTrailer(key, value, schema); + +/** + * Core service that decodes and encodes commit messages using the injected helpers. + * Allows customizing schema/validation, parser behavior, normalizers, and formatter hooks. + */ +export default class TrailerCodecService { + /** + * @param {Object} [options] + * @param {import('../schemas/GitTrailerSchema.js').TrailerSchemaBundle} [options.schemaBundle] - schema, keyPattern, keyRegex. + * @param {(key:string,value:string,schema:import('zod').ZodSchema) => GitTrailer} [options.trailerFactory] + * @param {TrailerParser} [options.parser] + * @param {MessageNormalizer} [options.messageNormalizer] + * @param {(lines: string[]) => { title: string; nextIndex: number }} [options.titleExtractor] + * @param {(lines: string[]) => string} [options.bodyComposer] + * @param {{ titleFormatter?: (value:string)=>string; bodyFormatter?: (value:string)=>string }} [options.formatters] + */ + constructor({ + schemaBundle = getDefaultTrailerSchemaBundle(), + trailerFactory = defaultTrailerFactory, + parser = null, + messageNormalizer = new MessageNormalizer(), + titleExtractor = extractTitle, + bodyComposer = composeBody, + formatters = {}, + } = {}) { + this.schemaBundle = this._validateSchemaBundle(schemaBundle); + this.trailerFactory = trailerFactory; + this.parser = this._initializeParser(parser, this.schemaBundle.keyPattern); + this.messageNormalizer = messageNormalizer; + this.titleExtractor = titleExtractor; + this.bodyComposer = bodyComposer; + this.formatters = formatters; + } + + /** + * Validates that schemaBundle has required structure. + * @private + */ + _validateSchemaBundle(schemaBundle) { + if (!schemaBundle || typeof schemaBundle !== 'object') { + throw new TypeError('schemaBundle is required'); + } + if (!schemaBundle.keyPattern || !schemaBundle.schema) { + throw new TypeError('schemaBundle must include schema and keyPattern'); + } + return schemaBundle; + } + + /** + * Initializes parser with keyPattern if not provided. + * @private + */ + _initializeParser(parser, keyPattern) { + return parser ?? new TrailerParser({ keyPattern }); + } + + /** + * Normalizes the raw string, validates trailers, and returns the domain entity. + * @param {string} message + * @returns {GitCommitMessage} + */ + decode(message) { + if (!message) { + return new GitCommitMessage( + { title: '', body: '', trailers: [] }, + { trailerSchema: this.schemaBundle.schema } + ); + } + + this._guardMessageSize(message); + const lines = this._prepareLines(message); + const { title, nextIndex } = this._consumeTitle(lines); + const remainder = lines.slice(nextIndex); + const { bodyLines, trailerLines } = this.parser.split(remainder); + const body = this._composeBody(bodyLines); + const trailers = this._buildTrailers(trailerLines); + + return new GitCommitMessage( + { title, body, trailers }, + { trailerSchema: this.schemaBundle.schema, formatters: this.formatters } + ); + } + + /** + * Serializes a GitCommitMessage entity or raw payload, applying schema validation/formatters. + * @param {GitCommitMessage|import('../entities/GitCommitMessage.js').GitCommitMessageInput} messageEntity + * @returns {string} + */ + encode(messageEntity) { + if (!messageEntity) { + throw new TypeError('messageEntity is required'); + } + const commitMessage = + messageEntity instanceof GitCommitMessage + ? messageEntity + : new GitCommitMessage(messageEntity, { + trailerSchema: this.schemaBundle.schema, + formatters: this.formatters, + }); + return commitMessage.toString(); + } + + /** + * @private + * @param {string} message + * @returns {string[]} + */ + _prepareLines(message) { + return this.messageNormalizer.normalizeLines(message); + } + + /** + * @private + * @param {string[]} lines + */ + _consumeTitle(lines) { + return this.titleExtractor(lines); + } + + /** + * @private + * @param {string[]} lines + */ + _composeBody(lines) { + return this.bodyComposer(lines); + } + + /** + * @private + * @param {string[]} lines + * @returns {GitTrailer[]} + */ + _buildTrailers(lines) { + return lines.reduce((acc, line) => { + const match = this.parser.lineRegex.exec(line); + if (match) { + acc.push(this.trailerFactory(match[1], match[2], this.schemaBundle.schema)); + } + return acc; + }, []); + } + + /** + * @private + * @param {string} message + */ + _guardMessageSize(message) { + this.messageNormalizer.guardMessageSize(message); + } +} diff --git a/src/domain/services/TrailerParser.js b/src/domain/services/TrailerParser.js new file mode 100644 index 0000000..6490385 --- /dev/null +++ b/src/domain/services/TrailerParser.js @@ -0,0 +1,88 @@ +import { TRAILER_KEY_RAW_PATTERN_STRING } from '../schemas/GitTrailerSchema.js'; +import TrailerNoSeparatorError from '../errors/TrailerNoSeparatorError.js'; + +/** + * Parses trailer blocks from a normalized commit-body array. + * @property {RegExp} lineRegex – compiled regex used to capture `: ` per-line. + */ +export default class TrailerParser { + /** + * @param {Object} [options] + * @param {string} [options.keyPattern] – character class used to validate trailer keys (default `TRAILER_KEY_RAW_PATTERN_STRING`). + */ + constructor({ keyPattern = TRAILER_KEY_RAW_PATTERN_STRING } = {}) { + /** + * @private + * @type {string} + */ + this._keyPattern = keyPattern; + /** + * @type {RegExp} + */ + this.lineRegex = new RegExp(`^(${keyPattern}):\\s*(.*)$`); + } + + /** + * Splits the normalized lines into body lines and trailer lines. + * @param {string[]} lines – normalized line array (LF-only). + * @returns {{ trailerStart: number, bodyLines: string[], trailerLines: string[] }} + * @throws {TrailerNoSeparatorError} when trailers start immediately after a non-blank line. + */ + split(lines) { + const trailerStart = this._findTrailerStart(lines); + this._validateTrailerSeparation(lines, trailerStart); + return { + trailerStart, + bodyLines: lines.slice(0, trailerStart), + trailerLines: lines.slice(trailerStart), + }; + } + + /** + * Walks backward from the end of the message until the trailer block is found. + * @private + * @param {string[]} lines + * @returns {number} + */ + _findTrailerStart(lines) { + let trailerStart = lines.length; + const trailerLineTest = new RegExp(`^${this._keyPattern}: `); + + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (line === '') { + if (trailerStart === lines.length) { + continue; + } + break; + } + if (trailerLineTest.test(line)) { + trailerStart = i; + } else { + break; + } + } + + return trailerStart; + } + + /** + * Ensures there is a blank line separating the body from trailers. + * @private + * @param {string[]} lines + * @param {number} trailerStart + * @throws {TrailerNoSeparatorError} + */ + _validateTrailerSeparation(lines, trailerStart) { + if (trailerStart === lines.length) { + return; + } + const borderLine = trailerStart > 0 ? lines[trailerStart - 1] : ''; + if (borderLine.trim() !== '') { + throw new TrailerNoSeparatorError('Trailers must be separated from the body by a blank line', { + trailerStart, + borderLine, + }); + } + } +} diff --git a/src/domain/services/helpers/BodyComposer.js b/src/domain/services/helpers/BodyComposer.js new file mode 100644 index 0000000..c1d5863 --- /dev/null +++ b/src/domain/services/helpers/BodyComposer.js @@ -0,0 +1,26 @@ +/** + * Trims leading/trailing blank lines from the decoded body block while preserving interior spacing. + */ +/** + * Trim leading/trailing blank lines while preserving interior spacing. + * @param {string[]} lines + * @returns {string} + */ +export function composeBody(lines) { + let startIndex = 0; + let endIndex = lines.length; + + while (startIndex < endIndex && lines[startIndex].trim() === '') { + startIndex++; + } + + while (endIndex > startIndex && lines[endIndex - 1].trim() === '') { + endIndex--; + } + + if (startIndex >= endIndex) { + return ''; + } + + return lines.slice(startIndex, endIndex).join('\n'); +} diff --git a/src/domain/services/helpers/MessageNormalizer.js b/src/domain/services/helpers/MessageNormalizer.js new file mode 100644 index 0000000..3723c10 --- /dev/null +++ b/src/domain/services/helpers/MessageNormalizer.js @@ -0,0 +1,48 @@ +import TrailerTooLargeError from '../../errors/TrailerTooLargeError.js'; + +const DEFAULT_MAX_MESSAGE_SIZE = 5 * 1024 * 1024; + +const toUtf8Bytes = (input) => { + if (typeof input === 'string') { + return Buffer.byteLength(input, 'utf8'); + } + return Buffer.byteLength(String(input ?? ''), 'utf8'); +}; + +/** Normalizes commit text by guarding size and unifying CRLF to LF. */ +export default class MessageNormalizer { + /** + * @param {Object} [options] + * @param {number} [options.maxMessageSize=DEFAULT_MAX_MESSAGE_SIZE] - Max allowed message size in bytes. + */ + constructor({ maxMessageSize = DEFAULT_MAX_MESSAGE_SIZE } = {}) { + if (!Number.isFinite(maxMessageSize) || maxMessageSize <= 0) { + throw new TypeError('maxMessageSize must be a positive number'); + } + this.maxMessageSize = maxMessageSize; + } + + /** + * Throws when the raw message exceeds the configured byte limit. + * @param {string|unknown} message + */ + guardMessageSize(message) { + const messageBytes = toUtf8Bytes(message); + if (messageBytes > this.maxMessageSize) { + throw new TrailerTooLargeError(`Message exceeds ${this.maxMessageSize} bytes`, { + messageByteLength: messageBytes, + maxSize: this.maxMessageSize, + }); + } + } + + /** + * Normalizes CRLF (`\r\n`) to LF (`\n`) and splits into lines. + * @param {string|unknown} message + * @returns {string[]} + */ + normalizeLines(message) { + const payload = typeof message === 'string' ? message : String(message ?? ''); + return payload.replace(/\r\n/g, '\n').split('\n'); + } +} diff --git a/src/domain/services/helpers/TitleExtractor.js b/src/domain/services/helpers/TitleExtractor.js new file mode 100644 index 0000000..c386148 --- /dev/null +++ b/src/domain/services/helpers/TitleExtractor.js @@ -0,0 +1,12 @@ +/** + * Extracts the title line and the body start index. + * @param {string[]} lines – normalized commit lines. + */ +export function extractTitle(lines) { + const title = (lines[0] || '').trim(); + let nextIndex = 1; + while (nextIndex < lines.length && lines[nextIndex].trim() === '') { + nextIndex++; + } + return { title, nextIndex }; +} diff --git a/src/domain/value-objects/GitTrailer.js b/src/domain/value-objects/GitTrailer.js new file mode 100644 index 0000000..0f247cd --- /dev/null +++ b/src/domain/value-objects/GitTrailer.js @@ -0,0 +1,84 @@ +import { GitTrailerSchema } from '../schemas/GitTrailerSchema.js'; +import TrailerInvalidError from '../errors/TrailerInvalidError.js'; +import TrailerValueInvalidError from '../errors/TrailerValueInvalidError.js'; +import { ZodError } from 'zod'; +const DOCS_CUSTOM_VALIDATION = 'docs/ADVANCED.md#custom-validation-rules'; + +/** + * Value object representing a Git trailer (key-value pair). + * Keys are normalized to lowercase and values trimmed before serialization. + */ +export default class GitTrailer { + /** + * @param {string} key - Raw trailer key (e.g., Accepted). + * @param {string} value - Raw trailer value. + * @param {import('zod').ZodSchema} [schema=GitTrailerSchema] - Schema validating the pair. + * @throws {TrailerInvalidError|TrailerValueInvalidError} when the schema rejects the pair. + */ + constructor(key, value, schema = GitTrailerSchema) { + const actualSchema = this._validateSchema(schema); + const normalizedKey = String(key ?? '').toLowerCase(); + const normalizedValue = String(value ?? '').trim(); + + try { + actualSchema.parse({ key: normalizedKey, value: normalizedValue }); + this.key = normalizedKey; + this.value = normalizedValue; + } catch (error) { + throw this._handleValidationError(error, normalizedKey, value); + } + } + + /** + * Validates that schema has required parse method. + * @private + */ + _validateSchema(schema) { + const actualSchema = schema ?? GitTrailerSchema; + if (!actualSchema || typeof actualSchema.parse !== 'function') { + throw new TypeError('Invalid schema: missing parse method'); + } + return actualSchema; + } + + /** + * Handles validation errors from Zod parsing. + * @private + */ + _handleValidationError(error, normalizedKey, rawValue) { + if (!(error instanceof ZodError)) { + return error; + } + + const valueIssue = error.issues.some((issue) => issue.path.includes('value')); + const ErrorClass = valueIssue ? TrailerValueInvalidError : TrailerInvalidError; + const truncatedValue = this._truncateValue(String(rawValue ?? '')); + const issueMessages = error.issues.map((issue) => issue.message).join(', '); + + return new ErrorClass( + `Invalid trailer '${normalizedKey.toLowerCase()}' (value='${truncatedValue}'): ${issueMessages}. See ${DOCS_CUSTOM_VALIDATION}.`, + { + issues: error.issues, + key: normalizedKey.toLowerCase(), + truncatedValue, + docs: DOCS_CUSTOM_VALIDATION, + } + ); + } + + /** + * Truncates long values for error messages. + * @private + */ + _truncateValue(value) { + return value.length > 120 ? `${value.slice(0, 120)}…[truncated]` : value; + } + + toString() { + return `${this.key}: ${this.value}`; + } + + toJSON() { + return { key: this.key, value: this.value }; + } +} diff --git a/src/utils/zodValidator.js b/src/utils/zodValidator.js new file mode 100644 index 0000000..970e466 --- /dev/null +++ b/src/utils/zodValidator.js @@ -0,0 +1,44 @@ +import { z } from 'zod'; + +const schemaRegistry = new Map(); + +/** + * Registers a Zod schema under a symbolic name. + * @param {string} typeId + * @param {z.ZodTypeAny} schema + */ +export function registerSchema(typeId, schema) { + if (!typeId || typeof typeId !== 'string') { + throw new TypeError('typeId must be a non-empty string'); + } + if (schemaRegistry.has(typeId)) { + throw new Error(`schema already registered for ${typeId}`); + } + schemaRegistry.set(typeId, schema); +} + +/** + * Validates a value using the schema registered for the given type. + * Throws the underlying ZodError when validation fails. + * @param {string} typeId + * @param {unknown} value + * @returns {unknown} + */ +export function validateType(typeId, value) { + const schema = schemaRegistry.get(typeId); + if (!schema) { + throw new Error(`no schema registered for ${typeId}`); + } + const result = schema.safeParse(value); + if (!result.success) { + throw result.error; + } + return result.data; +} + +const codecOptionsSchema = z.object({ + keyPattern: z.union([z.string(), z.instanceof(RegExp)]).optional(), + keyMaxLength: z.number().int().positive().optional(), +}); + +registerSchema('createConfiguredCodecOptions', codecOptionsSchema); diff --git a/test/unit/adapters/CodecBuilder.test.js b/test/unit/adapters/CodecBuilder.test.js new file mode 100644 index 0000000..adfe877 --- /dev/null +++ b/test/unit/adapters/CodecBuilder.test.js @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { createConfiguredCodec } from '../../../src/adapters/CodecBuilder.js'; + +describe('createConfiguredCodec', () => { + it('returns helpers bound to a configured service', () => { + const { decodeMessage } = createConfiguredCodec({ + keyPattern: '[A-Z]+', + keyMaxLength: 10, + }); + const encoded = 'Title\n\nVALUE: foo'; + const decoded = decodeMessage(encoded); + + expect(decoded.trailers).toHaveProperty('value', 'foo'); + }); + + it('allows parser options overrides', () => { + const { encodeMessage } = createConfiguredCodec({ + parserOptions: { keyPattern: '[A-Za-z]+\\b' }, + }); + + const output = encodeMessage({ + title: 'Title', + trailers: { 'CustomKey': 'ok' }, + }); + + expect(output).toContain('customkey: ok'); + }); +}); diff --git a/test/unit/adapters/FacadeAdapter.test.js b/test/unit/adapters/FacadeAdapter.test.js new file mode 100644 index 0000000..8edf0c9 --- /dev/null +++ b/test/unit/adapters/FacadeAdapter.test.js @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import TrailerCodec from '../../../src/adapters/FacadeAdapter.js'; +import TrailerCodecService from '../../../src/domain/services/TrailerCodecService.js'; + +describe('TrailerCodec', () => { + it('has both decodeMessage and decode aliases', () => { + const service = new TrailerCodecService(); + const codec = new TrailerCodec({ service }); + + expect(typeof codec.decodeMessage).toBe('function'); + expect(typeof codec.decode).toBe('function'); + }); + + it('has both encodeMessage and encode aliases', () => { + const service = new TrailerCodecService(); + const codec = new TrailerCodec({ service }); + + expect(typeof codec.encodeMessage).toBe('function'); + expect(typeof codec.encode).toBe('function'); + }); + + it('decode() alias works identically to decodeMessage()', () => { + const service = new TrailerCodecService(); + const codec = new TrailerCodec({ service }); + const raw = 'Title\n\nBody\n\nKey: Value'; + + const result1 = codec.decodeMessage(raw); + const result2 = codec.decode(raw); + + expect(result1).toEqual(result2); + expect(result1.title).toBe('Title'); + expect(result1.body).toBe('Body'); + expect(result1.trailers).toEqual({ key: 'Value' }); + }); + + it('encode() alias works identically to encodeMessage()', () => { + const service = new TrailerCodecService(); + const codec = new TrailerCodec({ service }); + const payload = { title: 'Test', body: 'Content', trailers: { foo: 'bar' } }; + + const result1 = codec.encodeMessage(payload); + const result2 = codec.encode(payload); + + expect(result1).toBe(result2); + expect(result1).toContain('Test'); + expect(result1).toContain('Content'); + expect(result1).toContain('foo: bar'); + }); +}); diff --git a/test/unit/domain/entities/GitCommitMessage.test.js b/test/unit/domain/entities/GitCommitMessage.test.js new file mode 100644 index 0000000..6640aab --- /dev/null +++ b/test/unit/domain/entities/GitCommitMessage.test.js @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import GitCommitMessage from '../../../../src/domain/entities/GitCommitMessage.js'; +import GitTrailer from '../../../../src/domain/value-objects/GitTrailer.js'; +import CommitMessageInvalidError from '../../../../src/domain/errors/CommitMessageInvalidError.js'; + +describe('GitCommitMessage', () => { + it('creates a valid commit message', () => { + const msg = new GitCommitMessage({ + title: 'feat: add feature', + body: 'This is a description.', + trailers: [{ key: 'Signed-off-by', value: 'James' }], + }); + + expect(msg.title).toBe('feat: add feature'); + expect(msg.body).toBe('This is a description.'); + expect(msg.trailers).toHaveLength(1); + expect(msg.trailers[0]).toBeInstanceOf(GitTrailer); + }); + + it('accepts existing GitTrailer instances', () => { + const trailer = new GitTrailer('key', 'value'); + const msg = new GitCommitMessage({ title: 'Title', trailers: [trailer] }); + expect(msg.trailers[0]).toBe(trailer); + }); + + it('throws on missing title', () => { + // @ts-ignore + expect(() => new GitCommitMessage({ body: 'body' })).toThrow(CommitMessageInvalidError); + }); + + it('throws on empty title', () => { + expect(() => new GitCommitMessage({ title: '' })).toThrow(CommitMessageInvalidError); + }); + + it('formats toString correctly with body and trailers', () => { + const msg = new GitCommitMessage({ + title: 'Title', + body: 'Body', + trailers: [{ key: 'Key', value: 'Value' }], + }); + + // Expect: + // Title + // + // Body + // + // key: Value + // + const expected = 'Title\n\nBody\n\nkey: Value\n'; + expect(msg.toString()).toBe(expected); + }); + + it('formats toString correctly without body', () => { + const msg = new GitCommitMessage({ + title: 'Title', + trailers: [{ key: 'Key', value: 'Value' }], + }); + const expected = 'Title\n\nkey: Value\n'; + expect(msg.toString()).toBe(expected); + }); + + it('formats toString correctly without trailers', () => { + const msg = new GitCommitMessage({ title: 'Title', body: 'Body' }); + const expected = 'Title\n\nBody\n'; + expect(msg.toString()).toBe(expected); + }); + + it('normalizes trailers key case when adding via constructor', () => { + const msg = new GitCommitMessage({ + title: 'Title', + trailers: [{ key: 'UPPER-CASE', value: 'value' }] + }); + expect(msg.trailers[0].key).toBe('upper-case'); + }); + + it('preserves trailer insertion order', () => { + const msg = new GitCommitMessage({ + title: 'Title', + trailers: [ + { key: 'First', value: '1' }, + { key: 'Second', value: '2' } + ] + }); + expect(msg.trailers[0].key).toBe('first'); + expect(msg.trailers[1].key).toBe('second'); + }); +}); diff --git a/test/unit/domain/services/TrailerCodecService.test.js b/test/unit/domain/services/TrailerCodecService.test.js new file mode 100644 index 0000000..008a225 --- /dev/null +++ b/test/unit/domain/services/TrailerCodecService.test.js @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import TrailerCodecService from '../../../../src/domain/services/TrailerCodecService.js'; +import GitCommitMessage from '../../../../src/domain/entities/GitCommitMessage.js'; +import TrailerNoSeparatorError from '../../../../src/domain/errors/TrailerNoSeparatorError.js'; +import TrailerValueInvalidError from '../../../../src/domain/errors/TrailerValueInvalidError.js'; +import TrailerTooLargeError from '../../../../src/domain/errors/TrailerTooLargeError.js'; + +describe('TrailerCodecService', () => { + const service = new TrailerCodecService(); + + it('decodes a simple message without trailers', () => { + const raw = 'Simple title\n\nSome body content.'; + const msg = service.decode(raw); + expect(msg.title).toBe('Simple title'); + expect(msg.body).toBe('Some body content.'); + expect(msg.trailers).toHaveLength(0); + }); + + it('decodes a message with trailers', () => { + const raw = 'Title\n\nBody.\n\nSigned-off-by: Me\nChange-Id: 123'; + const msg = service.decode(raw); + expect(msg.title).toBe('Title'); + expect(msg.body).toBe('Body.'); + expect(msg.trailers).toHaveLength(2); + expect(msg.trailers[0].key).toBe('signed-off-by'); + expect(msg.trailers[0].value).toBe('Me'); + expect(msg.trailers[1].key).toBe('change-id'); + expect(msg.trailers[1].value).toBe('123'); + }); + + it('handles messages with only title and trailers', () => { + const raw = 'Title\n\nKey: Value'; + const msg = service.decode(raw); + expect(msg.title).toBe('Title'); + expect(msg.body).toBe(''); + expect(msg.trailers).toHaveLength(1); + }); + + it('encodes a GitCommitMessage entity', () => { + const msg = new GitCommitMessage({ + title: 'Title', + trailers: [{ key: 'My-Key', value: 'MyValue' }] + }); + const encoded = service.encode(msg); + expect(encoded).toContain('Title'); + expect(encoded).toContain('my-key: MyValue'); + }); + + it('encodes a plain object by converting it to entity', () => { + const encoded = service.encode({ + title: 'Direct Object', + trailers: [{ key: 'Foo', value: 'Bar' }] + }); + expect(encoded).toContain('Direct Object'); + expect(encoded).toContain('foo: Bar'); + }); + + it('handles Windows line endings in decoding', () => { + const raw = 'Title\r\n\r\nBody\r\n\r\nKey: Value'; + const msg = service.decode(raw); + expect(msg.title).toBe('Title'); + expect(msg.trailers).toHaveLength(1); + expect(msg.trailers[0].value).toBe('Value'); + }); + + it('rejects trailers without a blank line separator', () => { + const raw = 'Title\nBody\nSigned-off-by: Me'; + expect(() => service.decode(raw)).toThrow(TrailerNoSeparatorError); + }); + + it('rejects trailer values containing line breaks', () => { + expect(() => service._buildTrailers(['Key: Value\nInjected'])).toThrow(TrailerValueInvalidError); + }); + + it('guards message size in helper', () => { + const oversize = 'a'.repeat(5 * 1024 * 1024 + 1); + expect(() => service._guardMessageSize(oversize)).toThrow(TrailerTooLargeError); + }); + + it('consumes title and blank separator without shifting lines', () => { + const lines = ['Title', '', 'Body']; + const { title, nextIndex } = service._consumeTitle(lines); + expect(title).toBe('Title'); + expect(nextIndex).toBe(2); + expect(lines).toEqual(['Title', '', 'Body']); + }); + + it('respects formatter hooks when provided', () => { + const serviceWithFormatters = new TrailerCodecService({ + formatters: { + titleFormatter: (value) => `(${value})`, + bodyFormatter: (value) => `[[${value}]]`, + }, + }); + const raw = 'Title \n\n Body '; + const msg = serviceWithFormatters.decode(raw); + expect(msg.title).toBe('(Title)'); + expect(msg.body).toBe('[[ Body ]]'); + }); +}); diff --git a/test/unit/domain/services/TrailerParser.test.js b/test/unit/domain/services/TrailerParser.test.js new file mode 100644 index 0000000..ecfea12 --- /dev/null +++ b/test/unit/domain/services/TrailerParser.test.js @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import TrailerParser from '../../../../src/domain/services/TrailerParser.js'; +import TrailerNoSeparatorError from '../../../../src/domain/errors/TrailerNoSeparatorError.js'; + +describe('TrailerParser', () => { + const parser = new TrailerParser(); + + it('splits body and trailer lines when a blank separator is present', () => { + const lines = ['Body line', '', 'Signed-off-by: Me', 'Change-Id: 123']; + const { bodyLines, trailerLines } = parser.split(lines.slice()); + expect(bodyLines).toEqual(['Body line', '']); + expect(trailerLines).toEqual(['Signed-off-by: Me', 'Change-Id: 123']); + }); + + it('throws when trailers are not separated by a blank line', () => { + const lines = ['Body line', 'Signed-off-by: Me']; + expect(() => parser.split(lines)).toThrow(TrailerNoSeparatorError); + }); + + it('returns the full message as body when there are no trailers', () => { + const lines = ['Body line', 'Another line']; + const { bodyLines, trailerLines } = parser.split(lines); + expect(bodyLines).toEqual(lines); + expect(trailerLines).toHaveLength(0); + }); +}); diff --git a/test/unit/domain/value-objects/GitTrailer.test.js b/test/unit/domain/value-objects/GitTrailer.test.js new file mode 100644 index 0000000..ec2c803 --- /dev/null +++ b/test/unit/domain/value-objects/GitTrailer.test.js @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import GitTrailer from '../../../../src/domain/value-objects/GitTrailer.js'; +import TrailerInvalidError from '../../../../src/domain/errors/TrailerInvalidError.js'; +import TrailerValueInvalidError from '../../../../src/domain/errors/TrailerValueInvalidError.js'; + +describe('GitTrailer', () => { + it('creates a valid trailer', () => { + const trailer = new GitTrailer('Signed-off-by', 'James Ross'); + expect(trailer.key).toBe('signed-off-by'); // Normalized + expect(trailer.value).toBe('James Ross'); + }); + + it('normalizes key case', () => { + const trailer = new GitTrailer('CO-AUTHORED-BY', 'Someone'); + expect(trailer.key).toBe('co-authored-by'); + }); + + it('trims value whitespace', () => { + const trailer = new GitTrailer('key', ' value '); + expect(trailer.value).toBe('value'); + }); + + it('throws error for invalid key characters', () => { + expect(() => new GitTrailer('Invalid Key!', 'value')).toThrow(TrailerInvalidError); + }); + + it('throws error for empty key', () => { + expect(() => new GitTrailer('', 'value')).toThrow(TrailerInvalidError); + }); + + it('throws error for empty value', () => { + expect(() => new GitTrailer('key', '')).toThrow(TrailerValueInvalidError); // Assuming schema requires min(1) + }); + + it('throws TrailerValueInvalidError when value includes newline', () => { + const attempt = () => { + try { + new GitTrailer('Key', 'Line\nBreak'); + } catch (error) { + expect(error).toBeInstanceOf(TrailerValueInvalidError); + throw error; + } + }; + + expect(attempt).toThrow(TrailerValueInvalidError); + }); + + it('includes documentation link in TrailerValueInvalidError metadata', () => { + const attempt = () => { + try { + new GitTrailer('Key', ''); + } catch (error) { + expect(error).toBeInstanceOf(TrailerValueInvalidError); + expect(error.meta.docs).toBe('docs/ADVANCED.md#custom-validation-rules'); + expect(/invalid trailer/i.test(error.message)).toBe(true); + throw error; + } + }; + + expect(attempt).toThrow(TrailerValueInvalidError); + }); + + it('converts to string correctly', () => { + const trailer = new GitTrailer('Key', 'Value'); + expect(trailer.toString()).toBe('key: Value'); + }); + + it('converts to JSON correctly', () => { + const trailer = new GitTrailer('Key', 'Value'); + expect(trailer.toJSON()).toEqual({ key: 'key', value: 'Value' }); + }); +}); diff --git a/test/unit/index.test.js b/test/unit/index.test.js new file mode 100644 index 0000000..261e81c --- /dev/null +++ b/test/unit/index.test.js @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createMessageHelpers, formatBodySegment } from '../../index.js'; + +describe('createMessageHelpers', () => { + it('throws a TypeError for primitive inputs', () => { + const service = { decode: vi.fn(() => ({})), encode: vi.fn(() => '') }; + const helpers = createMessageHelpers({ service }); + + expect(() => helpers.decodeMessage(123)).toThrow(TypeError); + expect(service.decode).not.toHaveBeenCalled(); + }); + + it('throws a TypeError for object inputs', () => { + const service = { decode: vi.fn(() => ({})), encode: vi.fn(() => '') }; + const helpers = createMessageHelpers({ service }); + + expect(() => helpers.decodeMessage({ foo: 'bar' })).toThrow(TypeError); + expect(service.decode).not.toHaveBeenCalled(); + expect(service.encode).not.toHaveBeenCalled(); + }); + + it('throws a TypeError for objects with message property', () => { + const service = { decode: vi.fn(() => ({ title: 'ok', body: '', trailers: [] })), encode: vi.fn(() => 'ok') }; + const helpers = createMessageHelpers({ service }); + + expect(() => helpers.decodeMessage({ message: 'Title\n\n' })).toThrow(TypeError); + expect(service.decode).not.toHaveBeenCalled(); + }); + + it('honors body format options for trailing newline', () => { + const service = { decode: vi.fn(() => ({ title: 'with body', body: 'content', trailers: [] })), encode: vi.fn() }; + const helpers = createMessageHelpers({ service, bodyFormatOptions: { keepTrailingNewline: true } }); + const input = 'ignored'; + const output = helpers.decodeMessage(input); + + expect(output.body).toBe('content\n'); + expect(service.decode).toHaveBeenCalledWith(input); + expect(service.encode).not.toHaveBeenCalled(); + }); + + it('defaults to trimmed body without newline', () => { + const helpers = createMessageHelpers({ + service: { decode: vi.fn(() => ({ title: 'ok', body: ' trimmed ', trailers: [] })), encode: vi.fn(() => '') }, + }); + const output = helpers.decodeMessage('ignored'); + + expect(output.body).toBe('trimmed'); + }); + + it('throws if the service returns a null trailers array', () => { + const service = { decode: vi.fn(() => ({ title: 'ok', body: '', trailers: null })), encode: vi.fn(() => '') }; + const helpers = createMessageHelpers({ service }); + expect(() => helpers.decodeMessage('ignored')).toThrow(TypeError); + }); + + it('throws when duplicate trailer keys are returned', () => { + const service = { + decode: vi.fn(() => ({ + title: 'ok', + body: '', + trailers: [ + { key: 'foo', value: '1' }, + { key: 'foo', value: '2' }, + ], + })), + encode: vi.fn(() => ''), + }; + const helpers = createMessageHelpers({ service }); + expect(() => helpers.decodeMessage('ignored')).toThrow(/Duplicate trailer key/); + }); +}); + +describe('formatBodySegment', () => { + it('returns trimmed segments by default', () => { + expect(formatBodySegment(' hello ')).toBe('hello'); + expect(formatBodySegment('')).toBe(''); + }); + + it('appends newline when requested', () => { + expect(formatBodySegment('data', { keepTrailingNewline: true })).toBe('data\n'); + }); +});