diff --git a/.devcontainer/bun/devcontainer.json b/.devcontainer/bun/devcontainer.json new file mode 100644 index 0000000..d0aea90 --- /dev/null +++ b/.devcontainer/bun/devcontainer.json @@ -0,0 +1,18 @@ +{ + "name": "Bun (Git Stunts Plumbing)", + "build": { + "dockerfile": "../../Dockerfile.bun", + "context": "../.." + }, + "customizations": { + "vscode": { + "extensions": [ + "oven.bun-vscode", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode" + ] + } + }, + "postCreateCommand": "bun install", + "remoteUser": "root" +} diff --git a/.devcontainer/deno/devcontainer.json b/.devcontainer/deno/devcontainer.json new file mode 100644 index 0000000..7e50f7a --- /dev/null +++ b/.devcontainer/deno/devcontainer.json @@ -0,0 +1,20 @@ +{ + "name": "Deno (Git Stunts Plumbing)", + "build": { + "dockerfile": "../../Dockerfile.deno", + "context": "../.." + }, + "customizations": { + "vscode": { + "settings": { + "deno.enable": true, + "deno.lint": true, + "deno.unstable": true + }, + "extensions": [ + "denoland.vscode-deno" + ] + } + }, + "remoteUser": "root" +} diff --git a/.devcontainer/node/devcontainer.json b/.devcontainer/node/devcontainer.json new file mode 100644 index 0000000..248f2d5 --- /dev/null +++ b/.devcontainer/node/devcontainer.json @@ -0,0 +1,18 @@ +{ + "name": "Node.js (Git Stunts Plumbing)", + "build": { + "dockerfile": "../../Dockerfile.node", + "context": "../.." + }, + "customizations": { + "vscode": { + "extensions": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "vitest.explorer" + ] + } + }, + "postCreateCommand": "npm install", + "remoteUser": "root" +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ff4895c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.git +.crush +scripts +docker-compose.yml +Dockerfile.* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f2705d7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +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-multi-runtime: + 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 + - name: Run multi-runtime tests in Docker + run: npm test \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..83f456f --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,50 @@ +# Architecture & Design + +This project is built as a robust, low-level building block for Git-based applications. It follows strict engineering standards to ensure it is the most reliable Git plumbing library in the JavaScript ecosystem. + +## ๐Ÿ—๏ธ Hexagonal Architecture (Ports & Adapters) + +The codebase is strictly partitioned into three layers: + +### 1. The Domain (Core) +Contains the business logic, entities, and value objects. It is **pure** and has zero dependencies on infrastructure or specific runtimes. +- **Entities**: `GitCommit`, `GitTree`, `GitBlob`. +- **Value Objects**: `GitSha`, `GitRef`, `GitFileMode`, `GitSignature`. +- **Services**: `CommandSanitizer` (security), `ExecutionOrchestrator` (retry/backoff), `GitErrorClassifier`, `GitPersistenceService`, `ByteMeasurer`. + +### 2. The Ports (Contracts) +Functional interfaces that define how the domain interacts with the outside world. +- **`CommandRunner`**: A functional port defined in `src/ports/`. It enforces a strict contract: every command must return a `stdoutStream` and an `exitPromise`. + +## ๐Ÿ’‰ Dependency Injection + +Core services (`CommandSanitizer`, `ExecutionOrchestrator`) are designed as injectable instances. This allows developers to: +- Provide custom sanitization rules. +- Inject mock orchestrators for testing failure modes. +- Extend the `GitErrorClassifier` for specialized error handling. + +## ๐Ÿ›ก๏ธ Defense-in-Depth Validation + +We use **Zod** as our single source of truth for validation. +- **Schema Location**: All schemas reside in `src/domain/schemas/`. +- **Strict Enforcement**: No Entity or Value Object can be instantiated with invalid data. This ensures that errors are caught at the boundary, before any shell process is spawned. +- **JSON Schema Ready**: The Zod schemas are designed to be easily exportable to standard JSON schemas for cross-system interoperability. + +## ๐ŸŒŠ Streaming-Only Model + +In version 2.0.0, we eliminated the "buffered" execution path in the infrastructure layer. +- **Consistency**: Every runner behaves exactly the same way. +- **Memory Safety**: Large outputs (like `cat-file` on a massive blob) never hit the heap unless explicitly requested via `collect()`. +- **OOM Protection**: The `collect()` method enforces a `maxBytes` limit, preventing malicious or accidental memory exhaustion. + +## ๐Ÿงฉ Engineering Mandates + +1. **One File = One Class**: Every file in `src/` represents a single logical concept. No "utils.js" or "types.js" dumping grounds. +2. **Total JSDoc**: 100% of the public API is documented with JSDoc, enabling excellent IDE intellisense and automated documentation generation. +3. **Immutability**: All Value Objects are immutable. Operations that "change" a state (like `GitTree.addEntry`) return a new instance. +4. **No Magic Literals**: Constants like the `Empty Tree SHA`, default timeouts (120s), and buffer limits are exported from the port layer. + +## ๐Ÿงช Quality Assurance + +- **Multi-Runtime CI**: We don't just "test in Node". Our CI environment (via Docker Compose) runs the exact same test suite in Bun and Deno simultaneously. +- **Tests as Spec**: Our tests define the behavior of the system. A change in logic requires a change in the corresponding test to ensure the "red -> green" story is preserved. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9a82d74 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,97 @@ +# 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.7.0] - 2026-01-07 + +### Added +- **GitRepositoryService.save()**: Introduced a polymorphic persistence method that automatically delegates to the appropriate low-level operation based on the entity type (Blob, Tree, or Commit). +- **Commit Lifecycle Guide**: Created `docs/COMMIT_LIFECYCLE.md`, a step-by-step tutorial covering manual graph construction and persistence. + +### Changed +- **Documentation Overhaul**: Updated `README.md` with enhanced security details and prominent links to the new lifecycle guide. +- **Process Isolation**: Hardened shell runners with strict environment variable whitelisting and support for per-call overrides. +- **Runtime Optimization**: Updated `ByteMeasurer` to use `Buffer.byteLength` where available and pinned Deno to 2.6.3 in development environments. +- **Improved Validation**: Enhanced `GitRefSchema` to strictly follow Git's naming rules, including better handling of control characters and '@' symbol sequences. + +### Fixed +- **Node.js Shell Stability**: Resolved a critical bug in `NodeShellRunner` where processes were killed immediately if no timeout was specified. +- **Backoff Logic**: Fixed an off-by-one error in `ExecutionOrchestrator` that caused incorrect delay calculations during retries. +- **Type Safety**: Added type validation to `CommandSanitizer` to prevent `TypeError` when receiving non-string arguments. +- **Object Mapping**: Fixed a bug in `GitObjectType` where delta types were incorrectly mapped to strings instead of integers. +- **CI/CD Reliability**: Fixed GitHub Actions workflow by adding missing Node.js setup and dependency installation steps to the multi-runtime test job. +- **Persistence Accuracy**: Fixed incorrect tree entry type detection in `GitPersistenceService` that could cause tree corruption. + +## [2.5.0] - 2026-01-05 + +### Added +- **GitCommandBuilder Fluent API**: Added static factory methods for all whitelisted Git commands (e.g., `.hashObject()`, `.catFile()`, `.writeTree()`) and fluent flag methods (e.g., `.stdin()`, `.write()`, `.pretty()`) for a more expressive command-building experience. + +### Changed +- **GitPlumbing DI Support**: Updated the constructor to accept optional `sanitizer` and `orchestrator` instances, enabling full Dependency Injection for easier testing and customization of core logic. + +## [2.4.0] - 2026-01-03 + +### Added +- **GitErrorClassifier**: Extracted error categorization logic from the orchestrator into a dedicated domain service. Uses regex and exit codes (e.g., 128) to identify lock contention and state issues. +- **ProhibitedFlagError**: New specialized error thrown when restricted Git flags (like `--work-tree`) are detected, providing remediation guidance and documentation links. +- **Dynamic Command Registration**: Added `CommandSanitizer.allow(commandName)` to permit runtime extension of the allowed plumbing command list. + +### Changed +- **Dependency Injection (DI)**: Refactored `CommandSanitizer` and `ExecutionOrchestrator` into injectable class instances, improving testability and modularity of the `GitPlumbing` core. +- **Sanitizer Memoization**: Implemented an internal LRU-ish cache in `CommandSanitizer` to skip re-validation of identical repetitive commands, improving performance for high-frequency operations. +- **Enhanced Deno Shim**: Updated the test shim to include `beforeEach`, `afterEach`, and other lifecycle hooks for full parity with Vitest. + +## [2.3.0] - 2026-01-01 + +### Changed +- **Validation Unification**: Completed the migration from `ajv` to `zod` for the entire library, reducing bundle size and unifying the type-safety engine. +- **Security Hardening**: Expanded the `EnvironmentPolicy` whitelist to include `GIT_AUTHOR_TZ`, `GIT_COMMITTER_TZ`, and localization variables (`LANG`, `LC_ALL`, etc.) to ensure identity and encoding consistency. +- **Universal Testing**: Updated the multi-runtime test suite to ensure 100% test parity across Node.js, Bun, and Deno, specifically adding missing builder and environment tests. + +### Added +- **EnvironmentPolicy**: Extracted environment variable whitelisting into a dedicated domain service used by all shell runners. + +## [2.2.0] - 2025-12-28 + +### Added +- **ExecutionOrchestrator**: Extracted command execution lifecycle (retry, backoff, lock detection) into a dedicated domain service to improve SRP compliance. +- **Binary Stream Support**: Refactored `GitStream.collect()` to support raw `Uint8Array` accumulation, preventing corruption of non-UTF8 binary data (e.g., blobs, compressed trees). +- **GitRepositoryLockedError**: Introduced a specialized error for repository lock contention with remediation guidance. +- **CommandRetryPolicy**: Added a new value object to encapsulate configurable retry strategies and backoff logic. +- **Custom Runner Registration**: Added `ShellRunnerFactory.register()` to allow developers to inject custom shell execution logic (e.g., SSH, WASM). +- **Environment Overrides**: `GitPlumbing.createDefault()` and `ShellRunnerFactory.create()` now support explicit environment overrides. +- **Repository Factory**: Added `GitPlumbing.createRepository()` for single-line high-level service instantiation. +- **Workflow Recipes**: Created `docs/RECIPES.md` providing step-by-step guides for low-level Git workflows (e.g., 'Commit from Scratch'). + +### Changed +- **Memory Optimization**: Enhanced `GitStream.collect()` to use chunk-based accumulation with `Uint8Array.set()`, reducing redundant string allocations during collection. +- **Runtime Performance**: Optimized `ByteMeasurer` to use `Buffer.byteLength()` in Node.js and Bun, significantly improving performance for large string measurements. +- **Development Tooling**: Upgraded `vitest` to version 3.0.0 for improved testing capabilities and performance. + +## [2.1.0] - 2025-12-20 + +### Added +- **GitRepositoryService**: Extracted high-level repository operations (`revParse`, `updateRef`, `deleteRef`) into a dedicated domain service. +- **Resilience Layer**: Implemented exponential backoff retry logic for Git lock contention (`index.lock`) in `GitPlumbing.execute`. +- **Telemetric Trace IDs**: Added automatic and manual `traceId` correlation across command execution for production traceability. +- **Performance Monitoring**: Integrated latency tracking for all Git command executions. +- **Secure Runtime Adapters**: Implemented "Clean Environment" isolation in Node, Bun, and Deno runners, preventing sensitive env var leakage. +- **Resource Lifecycle Management**: Enhanced `GitStream` with `FinalizationRegistry` and `destroy()` for deterministic cleanup of shell processes. + +### Changed +- **Entity Unification**: Refactored `GitTreeEntry` to use object-based constructors, standardizing the entire domain entity API. +- **Hardened Sanitizer**: Strengthened `CommandSanitizer` to block configuration overrides (`-c`, `--config`) globally and expanded the plumbing command whitelist. +- **Enhanced Verification**: `GitPlumbing.verifyInstallation` now validates both the Git binary and the repository integrity of the current working directory. + +### Fixed +- **Deno Resource Leaks**: Resolved process leaks in Deno by ensuring proper stream consumption across all test cases. +- **Node.js Stream Performance**: Optimized async iteration in `GitStream` using native protocols. + +## [2.0.0] - 2025-12-10 + +### Added +- Initial release of the plumbing library. 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..9060549 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing to @git-stunts/plumbing + +First off, thank you for considering contributing to this project! It's people like you that make the open-source community such a great place to learn, inspire, and create. + +## ๐Ÿ“œ Code of Conduct + +By participating in this project, you are expected to uphold our Code of Conduct. Please be respectful and professional in all interactions. + +## ๐Ÿ› ๏ธ Development Process + +### Prerequisites +- Docker and Docker Compose +- Node.js (for local linting) +- **Windows Users**: Must use WSL or Git Bash to run shell-based test scripts locally. + +### Workflow +1. **Fork the repository** and create your branch from `main`. +2. **Install dependencies**: `npm install`. +3. **Make your changes**: Ensure you follow our architectural principles (SRP, one class per file, no magic values). +4. **Write tests**: Any new feature or fix *must* include corresponding tests. +5. **Verify locally**: + - Run linting: `npm run lint` + - Run cross-platform tests: `npm test` (requires Docker) +6. **Commit**: Use [Conventional Commits](https://www.conventionalcommits.org/) (e.g., `feat: ...`, `fix: ...`). +7. **Submit a Pull Request**: Provide a clear description of the changes and link to any relevant issues. + +## ๐Ÿ—๏ธ Architectural Principles +- **Hexagonal Architecture**: Keep the domain pure. Infrastructure details stay in `adapters`. +- **Value Objects**: Use Value Objects for all domain concepts (SHAs, Refs, Signatures). +- **Security First**: All shell commands must be sanitized via `CommandSanitizer`. +- **Environment Agnostic**: Use `TextEncoder`/`TextDecoder` and avoid runtime-specific APIs in the domain layer. + +## ๐Ÿž Reporting Bugs +- Use the GitHub issue tracker. +- Provide a minimal reproducible example. +- Include details about your environment (OS, runtime version). + +## ๐Ÿ“„ License +By contributing, you agree that your contributions will be licensed under its Apache-2.0 License. diff --git a/Dockerfile.bun b/Dockerfile.bun new file mode 100644 index 0000000..a1d3046 --- /dev/null +++ b/Dockerfile.bun @@ -0,0 +1,6 @@ +FROM oven/bun:latest +RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY . . +RUN bun install --ignore-scripts +CMD ["bun", "test"] \ No newline at end of file diff --git a/Dockerfile.deno b/Dockerfile.deno new file mode 100644 index 0000000..f310cec --- /dev/null +++ b/Dockerfile.deno @@ -0,0 +1,9 @@ +FROM denoland/deno:2.6.3 + +RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY . . + +CMD ["deno", "task", "test"] \ No newline at end of file diff --git a/Dockerfile.node b/Dockerfile.node new file mode 100644 index 0000000..af98bed --- /dev/null +++ b/Dockerfile.node @@ -0,0 +1,6 @@ +FROM node:20-slim +RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY . . +RUN npm install --ignore-scripts +CMD ["npm", "run", "test:local"] \ No newline at end of file diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..41d79e4 --- /dev/null +++ b/NOTICE @@ -0,0 +1,16 @@ +@git-stunts/plumbing +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 index 825328b..ca8a752 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,190 @@ # @git-stunts/plumbing -A robust, class-based wrapper for Git binary execution. Designed for "Git Stunts" applications that bypass the porcelain and interact directly with the object database. +A low-level, robust, and environment-agnostic Git plumbing library for the modern JavaScript ecosystem. Built with **Hexagonal Architecture** and **Domain-Driven Design (DDD)**, it provides a secure, streaming-first, and type-safe interface for Git operations across **Node.js, Bun, and Deno**. -## Features +## ๐Ÿš€ Key Features -- **Class-based API**: Encapsulates `cwd` and state. -- **Zero Dependencies**: Uses Node.js standard library. -- **Plumbing First**: Optimized for `commit-tree`, `hash-object`, and `update-ref`. -- **Telemetry**: Error messages include `stdout` and `stderr` for easier debugging. +- **Streaming-First Architecture**: Unified "Streaming Only" pattern across all runtimes for consistent, memory-efficient data handling. +- **Multi-Runtime Support**: Native adapters for Node.js, Bun, and Deno with automatic environment detection. +- **Robust Schema Validation**: Powered by **Zod**, ensuring every Entity and Value Object is valid before use. +- **Hexagonal Architecture**: Strict separation between core domain logic and infrastructure adapters. +- **Dependency Injection**: Core services like `CommandSanitizer` and `ExecutionOrchestrator` are injectable for maximum testability and customization. +- **Execution Orchestration**: Centralized retry and lock-detection logic powered by a dedicated `GitErrorClassifier`. +- **OOM Protection**: Integrated safety buffering (`GitStream.collect`) with configurable byte limits. +- **Type-Safe Domain**: Formalized Value Objects for `GitSha`, `GitRef`, `GitFileMode`, and `GitSignature`. +- **Hardened Security**: Integrated `CommandSanitizer` to prevent argument injection attacks and `EnvironmentPolicy` for clean process isolation. +- **Process Isolation**: Every Git process runs in a sanitized environment, whitelisting only essential variables (`GIT_AUTHOR_*`, `LANG`, etc.) to prevent leakage. +- **Dockerized CI**: Parallel test execution across all runtimes using isolated containers. -## Installation +## ๐Ÿ“‹ Prerequisites + +- **System Git**: Requires Git >= 2.30.0 installed on the host system. +- **Node.js**: >= 22.0.0 +- **Bun**: >= 1.3.5 +- **Deno**: >= 2.0.0 + +## ๐Ÿ“ฆ Installation ```bash npm install @git-stunts/plumbing ``` -## Usage +## ๐Ÿ› ๏ธ Usage -```javascript -import GitPlumbing from '@git-stunts/plumbing'; +### Zero-Config Initialization -const git = new GitPlumbing({ cwd: './my-repo' }); +Version 2.0.0 introduced `createDefault()`, and version 2.7.0 adds `createRepository()` which automatically detect your runtime and set up the appropriate runner for a fast, zero-config start. -// Create a blob -const blobOid = git.execute({ - args: ['hash-object', '-w', '--stdin'], - input: 'Hello world' +```javascript +import GitPlumbing, { GitSha } from '@git-stunts/plumbing'; + +// Get a high-level service in one line +const git = GitPlumbing.createRepository({ cwd: './my-repo' }); + +// Securely resolve references +const headSha = await git.revParse({ revision: 'HEAD' }); + +// Create a commit from files with built-in concurrency protection +const commitSha = await git.createCommitFromFiles({ + branch: 'refs/heads/main', + message: 'Feat: high-level orchestration', + author: { + name: 'James Ross', + email: 'james@flyingrobots.dev' + }, + committer: { + name: 'James Ross', + email: 'james@flyingrobots.dev' + }, + parents: [GitSha.from(headSha)], + files: [ + { path: 'hello.txt', content: 'Hello World' }, + { path: 'script.sh', content: '#!/bin/sh\necho hi', mode: '100755' } + ], + concurrency: 10 // Optional: limit parallel Git processes }); +``` -// Create a commit pointing to the empty tree -const commitSha = git.execute({ - args: ['commit-tree', git.emptyTree, '-m', 'Stunt #1'], -}); +### Custom Runners + +Extend the library for exotic environments like SSH or WASM by registering a custom runner class. + +```javascript +import GitPlumbing, { ShellRunnerFactory } from '@git-stunts/plumbing'; + +class MySshRunner { + async run({ command, args, cwd, input, timeout, env }) { + /* custom implementation returning { stdoutStream, exitPromise } */ + } +} + +// Register and use +ShellRunnerFactory.register('ssh', MySshRunner); +const git = GitPlumbing.createDefault({ env: 'ssh' }); +``` + +### Fluent Command Building + +Construct complex plumbing commands with a type-safe, fluent API. + +```javascript +import { GitCommandBuilder } from '@git-stunts/plumbing'; + +const args = GitCommandBuilder.hashObject() + .write() + .stdin() + .build(); // ['hash-object', '-w', '--stdin'] + +const catArgs = GitCommandBuilder.catFile() + .pretty() + .arg('HEAD:README.md') + .build(); // ['cat-file', '-p', 'HEAD:README.md'] +``` + +### Core Entities + +The library uses immutable Value Objects and Zod-validated Entities to ensure data integrity. + +```javascript +import { GitSha, GitRef, GitSignature } from '@git-stunts/plumbing'; + +// Validate and normalize SHAs (throws ValidationError if invalid) +const sha = GitSha.from('a1b2c3d4e5f67890123456789012345678901234'); -// Update a ref -git.updateRef({ - ref: 'refs/_blog/stunt', - newSha: commitSha +// Safe reference handling (implements git-check-ref-format) +const mainBranch = GitRef.branch('main'); + +// Structured signatures +const author = new GitSignature({ + name: 'James Ross', + email: 'james@flyingrobots.dev' }); ``` -## API +### Streaming Power + +All commands are streaming-first. You can consume them as async iterables or collect them with safety guards. + +```javascript +const stream = await git.executeStream({ args: ['cat-file', '-p', 'HEAD'] }); + +// Consume as async iterable +for await (const chunk of stream) { + process.stdout.write(chunk); +} + +// OR collect with OOM protection (default 10MB) +const output = await stream.collect({ maxBytes: 1024 * 1024, asString: true }); +``` + +### Binary Support + +You can now collect raw bytes to handle binary blobs without corruption. + +```javascript +const stream = await git.executeStream({ args: ['cat-file', '-p', 'HEAD:image.png'] }); +const buffer = await stream.collect({ asString: false }); // Returns Uint8Array +``` + +## ๐Ÿ—๏ธ Architecture + +This project strictly adheres to modern engineering principles: +- **1 File = 1 Class/Concept**: Modular, focused files for maximum maintainability. +- **Dependency Inversion (DI)**: Domain logic depends on functional ports, not runtime-specific APIs. +- **No Magic Values**: All internal constants, timeouts, and buffer limits are centralized in the port layer. +- **Serializability**: Every domain object implements `toJSON()` for seamless interoperability. + +For a deeper dive, see [ARCHITECTURE.md](./ARCHITECTURE.md). + +## ๐Ÿ“– Documentation + +- [**Git Commit Lifecycle**](./docs/COMMIT_LIFECYCLE.md) - **Recommended**: A step-by-step guide to building and persisting Git objects. +- [**Custom Runners**](./docs/CUSTOM_RUNNERS.md) - How to implement and register custom execution adapters. +- [**Architecture & Design**](./ARCHITECTURE.md) - Deep dive into the hexagonal architecture and design principles. +- [**Workflow Recipes**](./docs/RECIPES.md) - Step-by-step guides for common Git plumbing tasks (e.g., manual commits). +- [**Contributing**](./CONTRIBUTING.md) - Guidelines for contributing to the project. + +## ๐Ÿงช Testing + +We take cross-platform compatibility seriously. Our test suite runs in parallel across all supported runtimes using Docker. + +```bash +npm test # Multi-runtime Docker tests +npm run test:local # Local vitest run +``` + +## ๐Ÿ’ป Development -### `new GitPlumbing({ cwd })` -Creates a new instance tied to a specific directory. +### Dev Containers +Specialized environments are provided for each runtime. Open this project in VS Code and select a container: +- `.devcontainer/node` +- `.devcontainer/bun` +- `.devcontainer/deno` -### `execute({ args, input })` -Executes a git command. Throws if the command fails. +### Git Hooks +- **Pre-commit**: Runs ESLint to ensure code style and SRP adherence. +- **Pre-push**: Runs the full Docker-based multi-runtime test suite. -### `executeWithStatus({ args })` -Executes a git command and returns `{ stdout, status }`, allowing you to handle non-zero exit codes (like `git diff`) without throwing. +## ๐Ÿ“„ License -### `emptyTree` -Property returning the well-known SHA-1 of the empty tree. \ No newline at end of file +Apache-2.0 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8daf53b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,26 @@ +# Security Policy + +## Supported Versions + +Only the latest version of `@git-stunts/plumbing` is supported for security updates. + +| Version | Supported | +| ------- | ------------------ | +| latest | :white_check_mark: | +| < 1.0.0 | :x: | + +## Reporting a Vulnerability + +We take the security of this project seriously. If you believe you have found a security vulnerability, please report it to us by following these steps: + +1. **Do not open a public issue.** +2. Email your findings to `james@flyingrobots.dev`. +3. Include a detailed description of the vulnerability, steps to reproduce, and any potential impact. + +We will acknowledge receipt of your report within 48 hours and provide a timeline for resolution. We request that you follow coordinated disclosure and refrain from publishing information about the vulnerability until a fix has been released. + +### Hardened Scope +This project specifically focuses on preventing: +- **Argument Injection**: Malicious flags passed to Git CLI. +- **Path Traversal**: Unauthorized access outside of the repository's `cwd`. +- **ReDoS**: Regular expression denial of service in validation logic. diff --git a/ShellRunner.js b/ShellRunner.js index a13f5aa..3f7041b 100644 --- a/ShellRunner.js +++ b/ShellRunner.js @@ -1,7 +1,13 @@ -import { execFile } from 'node:child_process'; +/** + * @fileoverview ShellRunner facade - delegates to environment-specific implementation + */ + +import ShellRunnerFactory from './src/infrastructure/factories/ShellRunnerFactory.js'; +import { DEFAULT_COMMAND_TIMEOUT } from './src/ports/RunnerOptionsSchema.js'; /** - * ShellRunner provides a standard CommandRunner implementation using child_process.execFile. + * ShellRunner provides a standard CommandRunner implementation. + * It automatically detects the environment (Node, Bun, Deno) and uses the appropriate adapter. */ export default class ShellRunner { /** @@ -10,23 +16,14 @@ export default class ShellRunner { * @param {string} options.command * @param {string[]} options.args * @param {string} [options.cwd] - * @param {string|Buffer} [options.input] + * @param {string|Uint8Array} [options.input] * @returns {Promise<{stdout: string, stderr: string, code: number}>} */ - static async run({ command, args, cwd, input }) { - return new Promise((resolve) => { - const child = execFile(command, args, { cwd, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => { - resolve({ - stdout: stdout || '', - stderr: stderr || '', - code: error ? error.code : 0 - }); - }); - - if (input && child.stdin) { - child.stdin.write(input); - child.stdin.end(); - } + static async run(options) { + const runner = ShellRunnerFactory.create(); + return runner({ + timeout: DEFAULT_COMMAND_TIMEOUT, + ...options }); } -} +} \ No newline at end of file diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..6ace80a --- /dev/null +++ b/bun.lock @@ -0,0 +1,380 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "@git-stunts/plumbing", + "dependencies": { + "zod": "^3.24.1", + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "eslint": "^9.17.0", + "prettier": "^3.4.2", + "vitest": "^3.0.0", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "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" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="], + + "@eslint/js": ["@eslint/js@9.39.2", "", {}, "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.1", "", { "os": "android", "cpu": "arm" }, "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.55.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.55.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.55.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.55.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.55.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.55.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.55.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.55.1", "", { "os": "none", "cpu": "arm64" }, "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.55.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], + + "@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + + "@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="], + + "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + + "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + + "acorn": ["acorn@8.15.0", "", { "bin": "bin/acorn" }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.12.6", "", { "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" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.39.2", "", { "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" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": "bin/eslint.js" }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "optionator": ["optionator@0.9.4", "", { "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" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.7.4", "", { "bin": "bin/prettier.cjs" }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "rollup": ["rollup@4.55.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "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" }, "bin": "dist/bin/rollup" }, "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + + "vite-node": ["vite-node@3.2.4", "", { "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" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + + "vitest": ["vitest@3.2.4", "", { "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" }, "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": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + } +} diff --git a/contract.js b/contract.js deleted file mode 100644 index 94548d4..0000000 --- a/contract.js +++ /dev/null @@ -1,25 +0,0 @@ -import { z } from 'zod'; - -/** - * Zod schema for the result returned by a CommandRunner. - */ -export const RunnerResultSchema = z.object({ - stdout: z.string(), - stderr: z.string(), - code: z.number().optional().default(0), -}); - -/** - * Zod schema for CommandRunner options. - */ -export const RunnerOptionsSchema = z.object({ - command: z.string(), - args: z.array(z.string()), - cwd: z.string().optional(), - input: z.union([z.string(), z.instanceof(Buffer)]).optional(), -}); - -/** - * @typedef {z.infer} RunnerResult - * @typedef {z.infer} RunnerOptions - */ diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..fca042e --- /dev/null +++ b/deno.json @@ -0,0 +1,6 @@ +{ + "tasks": { + "test": "deno test --quiet --no-check --allow-read --allow-run --allow-env --allow-write test/deno_entry.js" + }, + "nodeModulesDir": "auto" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c7a4c8d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + node-test: + build: + context: . + dockerfile: Dockerfile.node + environment: + - NODE_ENV=test + + bun-test: + build: + context: . + dockerfile: Dockerfile.bun + + deno-test: + build: + context: . + dockerfile: Dockerfile.deno diff --git a/docs/COMMIT_LIFECYCLE.md b/docs/COMMIT_LIFECYCLE.md new file mode 100644 index 0000000..228154f --- /dev/null +++ b/docs/COMMIT_LIFECYCLE.md @@ -0,0 +1,85 @@ +# Git Commit Lifecycle Tutorial + +This guide walks you through the low-level construction of a Git commit using the `@git-stunts/plumbing` high-level domain entities and services. + +## ๐Ÿงฑ 1. Constructing GitBlobs + +The foundation of every Git repository is the blob (binary large object). Blobs store file content without filenames or metadata. + +```javascript +import { GitBlob } from '@git-stunts/plumbing'; + +// Create a blob from string content +const blob = GitBlob.fromContent('Hello, Git Plumbing!'); + +// Or from a Uint8Array +const binaryBlob = GitBlob.fromContent(new Uint8Array([1, 2, 3])); +``` + +## ๐ŸŒณ 2. Building GitTrees + +Trees map filenames to blobs (or other trees) and assign file modes. + +```javascript +import { GitTree, GitTreeEntry, GitSha } from '@git-stunts/plumbing'; + +// Define entries +const entry = new GitTreeEntry({ + path: 'hello.txt', + sha: GitSha.from('...'), // The SHA returned from persisting the blob + mode: '100644' +}); + +// Create the tree +const tree = new GitTree(null, [entry]); +``` + +## ๐Ÿ“ 3. Creating GitCommits + +Commits wrap trees with metadata like author, committer, and message. + +```javascript +import { GitCommit, GitSignature, GitSha } from '@git-stunts/plumbing'; + +const author = new GitSignature({ + name: 'James Ross', + email: 'james@flyingrobots.dev' +}); + +const commit = new GitCommit({ + sha: null, + treeSha: GitSha.from('...'), // The SHA returned from persisting the tree + parents: [], // Empty for root commit + author, + committer: author, + message: 'Feat: my first plumbing commit' +}); +``` + +## ๐Ÿ’พ 4. Persistence + +Use the `GitPersistenceService` to write these objects to the Git object database. + +```javascript +import GitPlumbing, { GitPersistenceService } from '@git-stunts/plumbing'; + +const git = GitPlumbing.createDefault(); +const persistence = new GitPersistenceService({ plumbing: git }); + +// Save objects +const blobSha = await persistence.writeBlob(blob); +const treeSha = await persistence.writeTree(tree); +const commitSha = await persistence.writeCommit(commit); +``` + +## ๐Ÿ”— 5. Updating References + +Finally, point a branch to your new commit. + +```javascript +const repo = GitPlumbing.createRepository(); +await repo.updateRef({ + ref: 'refs/heads/main', + newSha: commitSha +}); +``` diff --git a/docs/CUSTOM_RUNNERS.md b/docs/CUSTOM_RUNNERS.md new file mode 100644 index 0000000..dde3332 --- /dev/null +++ b/docs/CUSTOM_RUNNERS.md @@ -0,0 +1,75 @@ +# Custom Runners + +@git-stunts/plumbing is built on a Hexagonal Architecture, which means the core logic is decoupled from the infrastructure that actually executes the Git commands. This allows you to provide your own "Runner" to execute Git in non-standard environments. + +## The CommandRunner Contract + +A custom runner is a class that implements a `run` method. This method is the primary port for shell execution. + +### The `run` Method + +```typescript +async run(options: RunnerOptions): Promise +``` + +#### `RunnerOptions` + +The `options` object contains: + +- `command`: The binary to execute (always "git" for this library). +- `args`: An array of string arguments. +- `cwd`: The working directory for the process. +- `input`: Optional `string` or `Uint8Array` to be piped to `stdin`. +- `timeout`: Maximum execution time in milliseconds. +- `env`: An object containing environment variable overrides. + +#### `RunnerResult` + +The method must return a promise that resolves to an object containing: + +- `stdoutStream`: A `ReadableStream` (Web API) or `Readable` (Node.js) representing the stdout of the process. +- `exitPromise`: A promise that resolves when the process completes. + +The `exitPromise` must resolve to: + +```typescript +{ + code: number; // Exit code (0 for success) + stderr: string; // Captured stderr content + timedOut: boolean; // Whether the process was killed due to timeout +} +``` + +## Example: Implementing an SSH Runner + +If you need to execute Git commands on a remote server via SSH, you can implement a custom runner: + +```javascript +import { ShellRunnerFactory } from '@git-stunts/plumbing'; +import { Client } from 'ssh2'; // Hypothetical SSH library + +class SshRunner { + async run({ command, args, cwd, input, timeout, env }) { + const conn = new Client(); + await conn.connect({ /* ... */ }); + + // Implementation logic to spawn remote process, + // stream stdout, and capture exit code/stderr... + + return { + stdoutStream, // Must be a stream! + exitPromise: Promise.resolve({ code: 0, stderr: '', timedOut: false }) + }; + } +} + +// Register your runner with a unique name +ShellRunnerFactory.register('remote-ssh', SshRunner); + +// Use it when creating your plumbing instance +const git = GitPlumbing.createDefault({ env: 'remote-ssh' }); +``` + +## Why Streaming? + +The library enforces a streaming-only interface to ensure memory efficiency. Even for small commands, the runner must provide a stream. The `GitStream` wrapper in the core library will handle collecting this stream if a buffered result is needed, providing safety limits to prevent Out-Of-Memory (OOM) errors. diff --git a/docs/RECIPES.md b/docs/RECIPES.md new file mode 100644 index 0000000..7909c73 --- /dev/null +++ b/docs/RECIPES.md @@ -0,0 +1,115 @@ +# Git Plumbing Recipes + +This guide provides step-by-step instructions for common low-level Git workflows using `@git-stunts/plumbing`. + +## ๐Ÿ—๏ธ Commit from Scratch + +Creating a commit without using high-level porcelain commands (`git add`, `git commit`) involves four primary steps: hashing the content, building the tree, creating the commit object, and updating the reference. + +While `GitRepositoryService.createCommitFromFiles` handles this automatically, understanding the underlying plumbing is essential for complex graph manipulations. + +### 1. Hash the Content (Blob) +First, turn your files into Git blobs. Use the `GitPersistenceService` or raw execution. + +```javascript +import GitPlumbing, { GitBlob, GitSha } from '@git-stunts/plumbing'; + +const git = GitPlumbing.createDefault(); +const repo = GitPlumbing.createRepository({ plumbing: git }); + +// High-level way: +const blobSha = await repo.writeBlob(GitBlob.fromContent('Hello, Git!')); + +// Low-level way: +const shaStr = await git.execute({ + args: ['hash-object', '-w', '--stdin'], + input: 'Hello, Git!' +}); +const lowLevelSha = GitSha.from(shaStr.trim()); +``` + +### 2. Build the Tree +Create a tree object that maps filenames to the blobs created in step 1. + +```javascript +import { GitTree, GitTreeEntry } from '@git-stunts/plumbing'; + +const entry = new GitTreeEntry({ + path: 'hello.txt', + sha: blobSha, + mode: '100644' +}); + +const tree = new GitTree(null, [entry]); +const treeSha = await repo.writeTree(tree); +``` + +### 3. Create the Commit +Create a commit object that points to your tree. + +```javascript +import { GitCommit, GitSignature } from '@git-stunts/plumbing'; + +const sig = new GitSignature({ name: 'James', email: 'james@test.com' }); + +const commit = new GitCommit({ + sha: null, + treeSha, + parents: [], // Root commit + author: sig, + committer: sig, + message: 'Initial plumbing commit' +}); + +const commitSha = await repo.writeCommit(commit); +``` + +### 4. Update the Reference +Point your branch (e.g., `main`) to the new commit. + +```javascript +await repo.updateRef({ ref: 'refs/heads/main', newSha: commitSha }); +``` + +--- + +## ๐ŸŒŠ Streaming Large Blobs + +For large files, avoid buffering the entire content into memory by using the streaming API. + +```javascript +const stream = await git.executeStream({ + args: ['cat-file', '-p', 'HEAD:large-asset.bin'] +}); + +// Process chunks as they arrive +for await (const chunk of stream) { + // chunk is a Uint8Array + doSomethingWithChunk(chunk); +} +``` + +## ๐Ÿ› ๏ธ Handling Repository Locks + +If a command fails because the repository is locked, use a custom retry policy. + +```javascript +import { CommandRetryPolicy } from '@git-stunts/plumbing'; + +const policy = new CommandRetryPolicy({ + maxAttempts: 5, + initialDelayMs: 200, + totalTimeout: 5000 // 5 seconds max for the whole operation +}); + +try { + await git.execute({ + args: ['update-ref', 'refs/heads/main', newSha], + retryPolicy: policy + }); +} catch (err) { + if (err.name === 'GitRepositoryLockedError') { + console.error('Repository is locked. Remediation: ' + err.details.remediation); + } +} +``` diff --git a/eslint.config.js b/eslint.config.js index bb03811..8ae6595 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,50 +1,35 @@ -import js from "@eslint/js"; +import js from '@eslint/js'; +import globals from 'globals'; export default [ js.configs.recommended, { languageOptions: { ecmaVersion: 2022, - sourceType: "module", + sourceType: 'module', globals: { - process: "readonly", - Buffer: "readonly", - console: "readonly", - setTimeout: "readonly", - clearTimeout: "readonly" + ...globals.node, + ...globals.browser, // For TextEncoder/Decoder + Bun: 'readonly', + Deno: 'readonly', + globalThis: 'readonly' } }, rules: { - // Logic & Complexity - "complexity": ["error", 10], - "max-depth": ["error", 3], - "max-lines-per-function": ["error", 50], - "max-params": ["error", 3], - "max-nested-callbacks": ["error", 3], - - // Strictness - "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"], - "no-return-await": "off", // We want explicit returns in some stunts - "consistent-return": "error", - "no-shadow": "error", - "no-use-before-define": ["error", { "functions": false }], - - // Style (that affects logic) - "no-lonely-if": "error", - "no-unneeded-ternary": "error", - "one-var": ["error", "never"] + 'no-unused-vars': ['error', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_' + }], + 'no-console': 'off' + } + }, + { + files: ['test/**/*.js', 'test.js', '**/*.test.js'], + languageOptions: { + globals: { + ...globals.jest, + fail: 'readonly' + } } } ]; \ No newline at end of file diff --git a/index.js b/index.js index 8463c01..cf1b3d9 100644 --- a/index.js +++ b/index.js @@ -1,117 +1,248 @@ -import { RunnerOptionsSchema, RunnerResultSchema } from './contract.js'; +/** + * @fileoverview GitPlumbing - The primary domain service for Git plumbing operations + */ + +import { RunnerOptionsSchema, DEFAULT_MAX_BUFFER_SIZE } from './src/ports/RunnerOptionsSchema.js'; + +// Value Objects +import GitSha from './src/domain/value-objects/GitSha.js'; +import GitRef from './src/domain/value-objects/GitRef.js'; +import GitSignature from './src/domain/value-objects/GitSignature.js'; +import CommandRetryPolicy from './src/domain/value-objects/CommandRetryPolicy.js'; + +// Entities +import GitBlob from './src/domain/entities/GitBlob.js'; +import GitTree from './src/domain/entities/GitTree.js'; + +// Services +import GitPlumbingError from './src/domain/errors/GitPlumbingError.js'; +import InvalidArgumentError from './src/domain/errors/InvalidArgumentError.js'; +import CommandSanitizer from './src/domain/services/CommandSanitizer.js'; +import ShellRunnerFactory from './src/infrastructure/factories/ShellRunnerFactory.js'; +import GitRepositoryService from './src/domain/services/GitRepositoryService.js'; +import ExecutionOrchestrator from './src/domain/services/ExecutionOrchestrator.js'; +import GitBinaryChecker from './src/domain/services/GitBinaryChecker.js'; +import GitCommandBuilder from './src/domain/services/GitCommandBuilder.js'; +import GitPersistenceService from './src/domain/services/GitPersistenceService.js'; + +// Infrastructure +import GitStream from './src/infrastructure/GitStream.js'; + +/** + * Named exports for public API + */ +export { + GitSha, + GitRef, + GitSignature, + GitBlob, + GitTree, + GitPersistenceService, + GitCommandBuilder, + ShellRunnerFactory, + GitPlumbingError, + InvalidArgumentError, + CommandRetryPolicy, + GitRepositoryService +}; /** * GitPlumbing provides a low-level, robust interface for executing Git plumbing commands. - * It follows Dependency Inversion by accepting a 'runner' for the actual execution. + * Adheres to Hexagonal Architecture by defining its dependencies via ports (CommandRunner). */ export default class GitPlumbing { /** * @param {Object} options - * @param {import('./contract.js').CommandRunner} options.runner - The async function that executes shell commands. - * @param {string} [options.cwd=process.cwd()] - The working directory for git operations. + * @param {import('./src/ports/CommandRunnerPort.js').CommandRunner} options.runner - The functional port for shell execution. + * @param {string} [options.cwd='.'] - The working directory for git operations. + * @param {CommandSanitizer} [options.sanitizer] - Injected sanitizer. + * @param {ExecutionOrchestrator} [options.orchestrator] - Injected orchestrator. + * @param {Object} [options.fsAdapter] - Optional filesystem adapter for CWD validation. */ - constructor({ runner, cwd = process.cwd() }) { + constructor({ + runner, + cwd = '.', + sanitizer = new CommandSanitizer(), + orchestrator = new ExecutionOrchestrator(), + fsAdapter = null + }) { if (typeof runner !== 'function') { - throw new Error('A functional runner is required for GitPlumbing'); + throw new InvalidArgumentError('A functional runner port is required for GitPlumbing', 'GitPlumbing.constructor'); } + + let resolvedCwd = cwd; + if (fsAdapter) { + try { + resolvedCwd = fsAdapter.resolve(cwd); + if (typeof fsAdapter.isDirectory === 'function' && !fsAdapter.isDirectory(resolvedCwd)) { + throw new Error('Not a directory'); + } + } catch (err) { + throw new InvalidArgumentError(`Invalid working directory: ${cwd}`, 'GitPlumbing.constructor', { cwd, error: err.message }); + } + } + + /** @private */ this.runner = runner; - this.cwd = cwd; + /** @private */ + this.cwd = resolvedCwd; + /** @private */ + this.sanitizer = sanitizer; + /** @private */ + this.orchestrator = orchestrator; + /** @private */ + this.checker = new GitBinaryChecker({ plumbing: this }); } /** - * Executes a git command asynchronously. + * Orchestrates a full commit sequence from content to reference update. + * Delegates to GitRepositoryService. * @param {Object} options - * @param {string[]} options.args - Array of git arguments. - * @param {string|Buffer} [options.input] - Optional stdin input. - * @returns {Promise} - The trimmed stdout. - * @throws {Error} - If the command fails (non-zero exit code). + * @returns {Promise} The resulting commit SHA. */ - async execute({ args, input }) { - const options = RunnerOptionsSchema.parse({ - command: 'git', - args, - cwd: this.cwd, - input, - }); - - const rawResult = await this.runner(options); - const result = RunnerResultSchema.parse(rawResult); - - if (result.code !== 0 && result.code !== undefined) { - const err = new Error(`Git command failed with code ${result.code}: git ${args.join(' ')} -${result.stderr}`); - err.stdout = result.stdout; - err.stderr = result.stderr; - err.code = result.code; - throw err; - } - - return result.stdout.trim(); + async commit(options) { + const repo = new GitRepositoryService({ plumbing: this }); + return repo.createCommitFromFiles(options); } /** - * Specifically handles commands that might exit with 1 (like diff). - * @param {Object} options - * @param {string[]} options.args - * @returns {Promise<{stdout: string, status: number}>} + * Factory method to create an instance with the default shell runner for the current environment. + * @param {Object} [options] + * @param {string} [options.cwd] + * @param {string} [options.env] - Override environment detection. + * @param {CommandSanitizer} [options.sanitizer] + * @param {ExecutionOrchestrator} [options.orchestrator] + * @returns {GitPlumbing} */ - async executeWithStatus({ args }) { - const options = RunnerOptionsSchema.parse({ - command: 'git', - args, - cwd: this.cwd, + static createDefault(options = {}) { + const env = options.env || globalThis.process?.env?.GIT_PLUMBING_ENV; + return new GitPlumbing({ + runner: ShellRunnerFactory.create({ env }), + ...options }); + } - const rawResult = await this.runner(options); - const result = RunnerResultSchema.parse(rawResult); + /** + * Factory method to create a high-level GitRepositoryService. + * @param {Object} [options] + * @returns {GitRepositoryService} + */ + static createRepository(options = {}) { + const plumbing = GitPlumbing.createDefault(options); + return new GitRepositoryService({ plumbing }); + } - return { - stdout: result.stdout.trim(), - status: result.code || 0, - }; + /** + * Verifies that the git binary is available and the CWD is a valid repository. + * @throws {GitPlumbingError} + */ + async verifyInstallation() { + await this.checker.check(); + const isInside = await this.checker.isInsideWorkTree(); + if (!isInside) { + throw new GitPlumbingError('Not inside a git work tree', 'GitPlumbing.verifyInstallation', { + code: 'GIT_NOT_IN_WORK_TREE' + }); + } } /** - * Returns the SHA-1 of the empty tree. - * @returns {string} + * Executes a git command asynchronously and buffers the result. + * Includes retry logic for lock contention and telemetry (Trace ID, Latency). + * @param {Object} options + * @param {string[]} options.args - Array of git arguments. + * @param {string|Uint8Array} [options.input] - Optional stdin input. + * @param {number} [options.maxBytes=DEFAULT_MAX_BUFFER_SIZE] - Maximum buffer size. + * @param {string} [options.traceId] - Correlation ID for the command. + * @param {CommandRetryPolicy} [options.retryPolicy] - Strategy for retrying failed commands. + * @returns {Promise} - The trimmed stdout. + * @throws {GitPlumbingError} - If the command fails or buffer is exceeded. */ - get emptyTree() { - return '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; + async execute({ + args, + input, + env, + maxBytes = DEFAULT_MAX_BUFFER_SIZE, + traceId = Math.random().toString(36).substring(7), + retryPolicy = CommandRetryPolicy.default() + }) { + return this.orchestrator.orchestrate({ + execute: async () => { + const stream = await this.executeStream({ args, input, env }); + const stdout = await stream.collect({ maxBytes, asString: true }); + const result = await stream.finished; + return { stdout, result }; + }, + retryPolicy, + args, + traceId + }); } /** - * Resolves a revision to a full SHA. + * Executes a git command asynchronously and returns a universal stream. * @param {Object} options - * @param {string} options.revision - * @returns {Promise} + * @param {string[]} options.args - Array of git arguments. + * @param {string|Uint8Array} [options.input] - Optional stdin input. + * @param {Object} [options.env] - Optional environment overrides. + * @returns {Promise} - The unified stdout stream wrapper. + * @throws {GitPlumbingError} - If command setup fails. */ - async revParse({ revision }) { + async executeStream({ args, input, env }) { + this.sanitizer.sanitize(args); + + const options = RunnerOptionsSchema.parse({ + command: 'git', + args, + cwd: this.cwd, + input, + env + }); + try { - return await this.execute({ args: ['rev-parse', revision] }); - } catch { - return null; + const result = await this.runner(options); + return new GitStream(result.stdoutStream, result.exitPromise); + } catch (err) { + if (err instanceof GitPlumbingError) { + throw err; + } + throw new GitPlumbingError(err.message, 'GitPlumbing.executeStream', { args, originalError: err }); } } /** - * Updates a reference to point to a new SHA. + * Executes a git command and returns both stdout and exit status without throwing on non-zero exit. * @param {Object} options - * @param {string} options.ref - * @param {string} options.newSha - * @param {string} [options.oldSha] + * @param {string[]} options.args - Array of git arguments. + * @param {number} [options.maxBytes] - Maximum buffer size. + * @returns {Promise<{stdout: string, status: number}>} */ - async updateRef({ ref, newSha, oldSha }) { - const args = ['update-ref', ref, newSha]; - if (oldSha) args.push(oldSha); - await this.execute({ args }); + async executeWithStatus({ args, maxBytes }) { + const startTime = performance.now(); + try { + const stream = await this.executeStream({ args }); + const stdout = await stream.collect({ maxBytes, asString: true }); + const result = await stream.finished; + + return { + stdout: stdout.trim(), + status: result.code || 0, + latency: performance.now() - startTime + }; + } catch (err) { + throw new GitPlumbingError(err.message, 'GitPlumbing.executeWithStatus', { + args, + originalError: err, + latency: performance.now() - startTime + }); + } } /** - * Deletes a reference. - * @param {Object} options - * @param {string} options.ref + * Returns the SHA-1 of the empty tree. + * @returns {string} */ - async deleteRef({ ref }) { - await this.execute({ args: ['update-ref', '-d', ref] }); + get emptyTree() { + return GitSha.EMPTY_TREE_VALUE; } } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..84e3da8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2676 @@ +{ + "name": "@git-stunts/plumbing", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@git-stunts/plumbing", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "zod": "^3.24.1" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "eslint": "^9.17.0", + "prettier": "^3.4.2", + "vitest": "^3.0.0" + }, + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.40.0", + "node": ">=20.0.0" + } + }, + "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/eslintrc/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/@eslint/eslintrc/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/@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/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/eslint/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/eslint/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/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-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", + "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": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json index 186a1c1..4c4fc6b 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,27 @@ { "name": "@git-stunts/plumbing", - "version": "1.0.0", + "version": "2.7.0", "description": "Git Stunts Lego Block: plumbing", "type": "module", "main": "index.js", + "exports": { + ".": "./index.js", + "./ShellRunner": "./ShellRunner.js", + "./sha": "./src/domain/value-objects/GitSha.js", + "./ref": "./src/domain/value-objects/GitRef.js", + "./mode": "./src/domain/value-objects/GitFileMode.js", + "./signature": "./src/domain/value-objects/GitSignature.js", + "./errors": "./src/domain/errors/GitPlumbingError.js" + }, + "engines": { + "node": ">=22.0.0", + "bun": ">=1.3.5", + "deno": ">=2.0.0" + }, "scripts": { - "test": "vitest run", + "test": "./scripts/run-multi-runtime-tests.sh", + "test:local": "vitest run --globals", + "prepare": "test -d .git && git config core.hooksPath scripts || true", "lint": "eslint .", "format": "prettier --write ." }, @@ -18,6 +34,6 @@ "@eslint/js": "^9.17.0", "eslint": "^9.17.0", "prettier": "^3.4.2", - "vitest": "^2.1.8" + "vitest": "^3.0.0" } -} \ No newline at end of file +} diff --git a/scripts/pre-commit b/scripts/pre-commit new file mode 100755 index 0000000..9472cb6 --- /dev/null +++ b/scripts/pre-commit @@ -0,0 +1,7 @@ +#!/bin/sh +echo "Running pre-commit linting..." +npm run lint +if [ $? -ne 0 ]; then + echo "Linting failed. Commit aborted." + exit 1 +fi diff --git a/scripts/pre-push b/scripts/pre-push new file mode 100755 index 0000000..69c1dd9 --- /dev/null +++ b/scripts/pre-push @@ -0,0 +1,7 @@ +#!/bin/sh +echo "Running pre-push tests..." +npm test +if [ $? -ne 0 ]; then + echo "Tests failed. Push aborted." + exit 1 +fi diff --git a/scripts/run-multi-runtime-tests.sh b/scripts/run-multi-runtime-tests.sh new file mode 100755 index 0000000..83466e0 --- /dev/null +++ b/scripts/run-multi-runtime-tests.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# run-tests.sh + +echo "๐Ÿš€ Starting multi-runtime Docker tests..." + +# Detect docker compose version +if docker compose version > /dev/null 2>&1; then + DOCKER_COMPOSE="docker compose" +elif docker-compose version > /dev/null 2>&1; then + DOCKER_COMPOSE="docker-compose" +else + echo "โŒ Error: docker compose not found" + exit 1 +fi + +echo "Using: $DOCKER_COMPOSE" + +# Run builds and tests +$DOCKER_COMPOSE up --build --remove-orphans + +# Check status of each container +EXIT_CODE=0 + +for service in node-test bun-test deno-test; do + STATUS=$($DOCKER_COMPOSE ps -a --format "{{.ExitCode}}" $service) + if [ "$STATUS" != "0" ]; then + echo "โŒ $service failed with exit code $STATUS" + EXIT_CODE=1 + else + echo "โœ… $service passed" + fi +done + +# Cleanup +$DOCKER_COMPOSE down + +exit $EXIT_CODE \ No newline at end of file diff --git a/src/domain/entities/GitBlob.js b/src/domain/entities/GitBlob.js new file mode 100644 index 0000000..5de9f53 --- /dev/null +++ b/src/domain/entities/GitBlob.js @@ -0,0 +1,89 @@ +/** + * @fileoverview GitBlob entity - represents a Git blob object + */ + +import GitSha from '../value-objects/GitSha.js'; +import GitObjectType from '../value-objects/GitObjectType.js'; +import ByteMeasurer from '../services/ByteMeasurer.js'; +import ValidationError from '../errors/ValidationError.js'; +import { GitBlobSchema } from '../schemas/GitBlobSchema.js'; + +/** + * Represents a Git blob object + */ +export default class GitBlob { + /** + * @param {GitSha|string|null} sha + * @param {string|Uint8Array} content + */ + constructor(sha, content) { + const data = { + sha: sha instanceof GitSha ? sha.toString() : sha, + content + }; + + const result = GitBlobSchema.safeParse(data); + if (!result.success) { + throw new ValidationError( + `Invalid blob: ${result.error.errors[0].message}`, + 'GitBlob.constructor', + { data, errors: result.error.errors } + ); + } + + this.sha = sha instanceof GitSha ? sha : (result.data.sha ? GitSha.from(result.data.sha) : null); + this._content = result.data.content instanceof Uint8Array ? new Uint8Array(result.data.content) : result.data.content; + } + + /** + * Returns the blob content + * @returns {string|Uint8Array} + */ + get content() { + return this._content instanceof Uint8Array ? new Uint8Array(this._content) : this._content; + } + + /** + * Creates a GitBlob from content + * @param {string|Uint8Array} content + * @returns {GitBlob} + */ + static fromContent(content) { + return new GitBlob(null, content); + } + + /** + * Checks if the blob has been written to the repository + * @returns {boolean} + */ + isWritten() { + return this.sha !== null; + } + + /** + * Returns the content size in bytes + * @returns {number} + */ + size() { + return ByteMeasurer.measure(this.content); + } + + /** + * Returns the blob type + * @returns {GitObjectType} + */ + type() { + return GitObjectType.blob(); + } + + /** + * Returns a JSON representation of the blob + * @returns {Object} + */ + toJSON() { + return { + sha: this.sha ? this.sha.toString() : null, + content: this._content instanceof Uint8Array ? Array.from(this._content) : this._content + }; + } +} diff --git a/src/domain/entities/GitCommit.js b/src/domain/entities/GitCommit.js new file mode 100644 index 0000000..124d978 --- /dev/null +++ b/src/domain/entities/GitCommit.js @@ -0,0 +1,122 @@ +/** + * @fileoverview GitCommit entity - represents a Git commit object + */ + +import GitSha from '../value-objects/GitSha.js'; +import GitSignature from '../value-objects/GitSignature.js'; +import GitObjectType from '../value-objects/GitObjectType.js'; +import ValidationError from '../errors/ValidationError.js'; +import { GitCommitSchema } from '../schemas/GitCommitSchema.js'; + +/** + * @typedef {import('../schemas/GitCommitSchema.js').GitCommit} GitCommitData + */ + +/** + * Represents a Git commit object + */ +export default class GitCommit { + /** + * @param {Object} options + * @param {GitSha|null} options.sha + * @param {GitSha} options.treeSha + * @param {GitSha[]} options.parents + * @param {GitSignature} options.author + * @param {GitSignature} options.committer + * @param {string} options.message + */ + constructor({ sha, treeSha, parents = [], author, committer, message }) { + if (sha !== null && !(sha instanceof GitSha)) { + this.sha = sha ? GitSha.from(sha) : null; + } else { + this.sha = sha; + } + + this.treeSha = treeSha instanceof GitSha ? treeSha : GitSha.from(treeSha); + + if (!Array.isArray(parents)) { + throw new ValidationError('parents must be an array of GitSha', 'GitCommit.constructor'); + } + this.parents = parents.map(p => (p instanceof GitSha ? p : GitSha.from(p))); + + this.author = author instanceof GitSignature ? author : new GitSignature(author); + this.committer = committer instanceof GitSignature ? committer : new GitSignature(committer); + + if (typeof message !== 'string') { + throw new ValidationError('message must be a string', 'GitCommit.constructor'); + } + this.message = message; + } + + /** + * Factory method to create a GitCommit from raw data with validation. + * @param {GitCommitData} data + * @returns {GitCommit} + */ + static fromData(data) { + const result = GitCommitSchema.safeParse(data); + if (!result.success) { + throw new ValidationError( + `Invalid commit data: ${result.error.errors[0].message}`, + 'GitCommit.fromData', + { data, errors: result.error.errors } + ); + } + + return new GitCommit({ + sha: result.data.sha ? GitSha.from(result.data.sha) : null, + treeSha: GitSha.from(result.data.treeSha), + parents: result.data.parents.map(p => GitSha.from(p)), + author: new GitSignature(result.data.author), + committer: new GitSignature(result.data.committer), + message: result.data.message + }); + } + + /** + * Checks if the commit has been written to the repository + * @returns {boolean} + */ + isWritten() { + return this.sha !== null; + } + + /** + * Returns the commit type + * @returns {GitObjectType} + */ + type() { + return GitObjectType.commit(); + } + + /** + * Returns if this is a root commit (no parents) + * @returns {boolean} + */ + isRoot() { + return this.parents.length === 0; + } + + /** + * Returns if this is a merge commit (multiple parents) + * @returns {boolean} + */ + isMerge() { + return this.parents.length > 1; + } + + /** + * Returns a JSON representation of the commit + * @returns {GitCommitData} + */ + toJSON() { + return { + sha: this.sha ? this.sha.toString() : null, + treeSha: this.treeSha.toString(), + parents: this.parents.map(p => p.toString()), + author: this.author.toJSON(), + committer: this.committer.toJSON(), + message: this.message + }; + } +} diff --git a/src/domain/entities/GitCommitBuilder.js b/src/domain/entities/GitCommitBuilder.js new file mode 100644 index 0000000..5f3814e --- /dev/null +++ b/src/domain/entities/GitCommitBuilder.js @@ -0,0 +1,115 @@ +/** + * @fileoverview GitCommitBuilder entity - provides a fluent API for commit construction + */ + +import GitCommit from './GitCommit.js'; +import GitSha from '../value-objects/GitSha.js'; +import GitSignature from '../value-objects/GitSignature.js'; + +/** + * Fluent builder for creating GitCommit instances + */ +export default class GitCommitBuilder { + constructor() { + this._sha = null; + this._treeSha = null; + this._parents = []; + this._author = null; + this._committer = null; + this._message = ''; + } + + /** + * Sets the commit SHA + * @param {GitSha|string|null} sha + * @returns {GitCommitBuilder} + */ + sha(sha) { + if (sha === null) { + this._sha = null; + return this; + } + this._sha = sha instanceof GitSha ? sha : GitSha.from(sha); + return this; + } + + /** + * Sets the tree SHA + * @param {GitSha|string|{sha: GitSha|string}} tree + * @returns {GitCommitBuilder} + */ + tree(tree) { + if (tree && typeof tree === 'object' && 'sha' in tree) { + this._treeSha = tree.sha instanceof GitSha ? tree.sha : GitSha.from(tree.sha); + } else { + this._treeSha = tree instanceof GitSha ? tree : GitSha.from(tree); + } + return this; + } + + /** + * Adds a parent commit SHA + * @param {GitSha|string} parentSha + * @returns {GitCommitBuilder} + */ + parent(parentSha) { + const sha = parentSha instanceof GitSha ? parentSha : GitSha.from(parentSha); + this._parents.push(sha); + return this; + } + + /** + * Sets the parents array + * @param {GitSha[]|string[]} parents + * @returns {GitCommitBuilder} + */ + parents(parents) { + this._parents = parents.map(p => (p instanceof GitSha ? p : GitSha.from(p))); + return this; + } + + /** + * Sets the author + * @param {GitSignature|Object} author + * @returns {GitCommitBuilder} + */ + author(author) { + this._author = author instanceof GitSignature ? author : new GitSignature(author); + return this; + } + + /** + * Sets the committer + * @param {GitSignature|Object} committer + * @returns {GitCommitBuilder} + */ + committer(committer) { + this._committer = committer instanceof GitSignature ? committer : new GitSignature(committer); + return this; + } + + /** + * Sets the commit message + * @param {string} message + * @returns {GitCommitBuilder} + */ + message(message) { + this._message = String(message); + return this; + } + + /** + * Builds the GitCommit + * @returns {GitCommit} + */ + build() { + return new GitCommit({ + sha: this._sha, + treeSha: this._treeSha, + parents: this._parents, + author: this._author, + committer: this._committer, + message: this._message + }); + } +} diff --git a/src/domain/entities/GitTree.js b/src/domain/entities/GitTree.js new file mode 100644 index 0000000..4805497 --- /dev/null +++ b/src/domain/entities/GitTree.js @@ -0,0 +1,127 @@ +/** + * @fileoverview GitTree entity - represents a Git tree object + */ + +import GitSha from '../value-objects/GitSha.js'; +import GitObjectType from '../value-objects/GitObjectType.js'; +import GitTreeEntry from './GitTreeEntry.js'; +import ValidationError from '../errors/ValidationError.js'; +import { GitTreeSchema } from '../schemas/GitTreeSchema.js'; + +/** + * @typedef {import('../schemas/GitTreeSchema.js').GitTree} GitTreeData + */ + +/** + * Represents a Git tree object + */ +export default class GitTree { + /** + * @param {GitSha|null} sha - The tree SHA + * @param {GitTreeEntry[]} entries - Array of GitTreeEntry instances + */ + constructor(sha = null, entries = []) { + if (sha !== null && !(sha instanceof GitSha)) { + throw new ValidationError('SHA must be a GitSha instance or null', 'GitTree.constructor'); + } + + // Enforce that entries are GitTreeEntry instances + this._entries = entries.map(entry => { + if (!(entry instanceof GitTreeEntry)) { + throw new ValidationError('All entries must be GitTreeEntry instances', 'GitTree.constructor'); + } + return entry; + }); + + this.sha = sha; + } + + /** + * Factory method to create a GitTree from raw data with validation. + * @param {GitTreeData} data + * @returns {GitTree} + */ + static fromData(data) { + const result = GitTreeSchema.safeParse(data); + if (!result.success) { + throw new ValidationError( + `Invalid tree data: ${result.error.errors[0].message}`, + 'GitTree.fromData', + { data, errors: result.error.errors } + ); + } + + const sha = result.data.sha ? GitSha.from(result.data.sha) : null; + const entries = result.data.entries.map(e => new GitTreeEntry(e)); + return new GitTree(sha, entries); + } + + /** + * Returns a copy of the tree entries + * @returns {GitTreeEntry[]} + */ + get entries() { + return [...this._entries]; + } + + /** + * Creates an empty GitTree + * @returns {GitTree} + */ + static empty() { + return new GitTree(GitSha.EMPTY_TREE, []); + } + + /** + * Adds an entry to the tree + * @param {GitTreeEntry} entry + * @returns {GitTree} + */ + addEntry(entry) { + if (!(entry instanceof GitTreeEntry)) { + throw new ValidationError('Entry must be a GitTreeEntry instance', 'GitTree.addEntry', { entry }); + } + return new GitTree(this.sha, [...this._entries, entry]); + } + + /** + * Serializes the tree entries into the format expected by `git mktree`. + * Format: \t + * @returns {string} + */ + toMktreeFormat() { + return this._entries + .map(entry => { + const type = entry.isTree() ? 'tree' : 'blob'; + return `${entry.mode} ${type} ${entry.sha}\t${entry.path}`; + }) + .join('\n') + '\n'; + } + + /** + * Checks if the tree has been written to the repository + * @returns {boolean} + */ + isWritten() { + return this.sha !== null; + } + + /** + * Returns the tree type + * @returns {GitObjectType} + */ + type() { + return GitObjectType.tree(); + } + + /** + * Returns a JSON representation of the tree + * @returns {GitTreeData} + */ + toJSON() { + return { + sha: this.sha ? this.sha.toString() : null, + entries: this._entries.map(e => e.toJSON()) + }; + } +} \ No newline at end of file diff --git a/src/domain/entities/GitTreeBuilder.js b/src/domain/entities/GitTreeBuilder.js new file mode 100644 index 0000000..3899074 --- /dev/null +++ b/src/domain/entities/GitTreeBuilder.js @@ -0,0 +1,49 @@ +/** + * @fileoverview GitTreeBuilder entity - provides efficient O(N) tree construction + */ + +import GitTree from './GitTree.js'; +import GitTreeEntry from './GitTreeEntry.js'; +import ValidationError from '../errors/ValidationError.js'; + +/** + * Fluent builder for creating GitTree instances efficiently + */ +export default class GitTreeBuilder { + constructor() { + this._entries = []; + } + + /** + * Adds an entry to the builder + * @param {GitTreeEntry} entry + * @returns {GitTreeBuilder} + */ + addEntry(entry) { + if (!(entry instanceof GitTreeEntry)) { + throw new ValidationError('Entry must be a GitTreeEntry instance', 'GitTreeBuilder.addEntry', { entry }); + } + this._entries.push(entry); + return this; + } + + /** + * Convenience method to add an entry from primitives + * @param {Object} options + * @param {string} options.path + * @param {GitSha|string} options.sha + * @param {GitFileMode|string} options.mode + * @returns {GitTreeBuilder} + */ + add({ path, sha, mode }) { + return this.addEntry(new GitTreeEntry({ mode, sha, path })); + } + + /** + * Builds the GitTree + * @returns {GitTree} + */ + build() { + return new GitTree(null, [...this._entries]); + } +} \ No newline at end of file diff --git a/src/domain/entities/GitTreeEntry.js b/src/domain/entities/GitTreeEntry.js new file mode 100644 index 0000000..defcbc4 --- /dev/null +++ b/src/domain/entities/GitTreeEntry.js @@ -0,0 +1,80 @@ +/** + * @fileoverview GitTreeEntry entity - represents an entry in a Git tree + */ + +import GitSha from '../value-objects/GitSha.js'; +import GitFileMode from '../value-objects/GitFileMode.js'; +import ValidationError from '../errors/ValidationError.js'; +import { GitTreeEntrySchema } from '../schemas/GitTreeEntrySchema.js'; + +/** + * @typedef {import('../schemas/GitTreeEntrySchema.js').GitTreeEntry} GitTreeEntryData + */ + +/** + * Represents an entry in a Git tree + */ +export default class GitTreeEntry { + /** + * @param {Object} options + * @param {GitFileMode|string} options.mode - File mode + * @param {GitSha|string} options.sha - Object SHA + * @param {string} options.path - File path + */ + constructor({ mode, sha, path }) { + const data = { + mode: mode instanceof GitFileMode ? mode.toString() : mode, + sha: sha instanceof GitSha ? sha.toString() : sha, + path + }; + + const result = GitTreeEntrySchema.safeParse(data); + if (!result.success) { + throw new ValidationError( + `Invalid tree entry: ${result.error.errors[0].message}`, + 'GitTreeEntry.constructor', + { data, errors: result.error.errors } + ); + } + + this.mode = mode instanceof GitFileMode ? mode : new GitFileMode(result.data.mode); + this.sha = sha instanceof GitSha ? sha : GitSha.from(result.data.sha); + this.path = result.data.path; + } + + /** + * Returns the object type + * @returns {import('../value-objects/GitObjectType.js').default} + */ + type() { + return this.mode.getObjectType(); + } + + /** + * Returns if the entry is a directory (tree) + * @returns {boolean} + */ + isTree() { + return this.mode.isTree(); + } + + /** + * Returns if the entry is a blob + * @returns {boolean} + */ + isBlob() { + return this.type().isBlob(); + } + + /** + * Returns a JSON representation of the entry + * @returns {GitTreeEntryData} + */ + toJSON() { + return { + mode: this.mode.toString(), + sha: this.sha.toString(), + path: this.path + }; + } +} \ No newline at end of file diff --git a/src/domain/errors/GitPlumbingError.js b/src/domain/errors/GitPlumbingError.js new file mode 100644 index 0000000..f678313 --- /dev/null +++ b/src/domain/errors/GitPlumbingError.js @@ -0,0 +1,15 @@ +/** + * @fileoverview Custom error types for Git plumbing operations + */ + +/** + * Base error for Git operations + */ +export default class GitPlumbingError extends Error { + constructor(message, operation, details = {}) { + super(message); + this.name = 'GitPlumbingError'; + this.operation = operation; + this.details = details; + } +} \ No newline at end of file diff --git a/src/domain/errors/GitRepositoryLockedError.js b/src/domain/errors/GitRepositoryLockedError.js new file mode 100644 index 0000000..ef46d58 --- /dev/null +++ b/src/domain/errors/GitRepositoryLockedError.js @@ -0,0 +1,25 @@ +/** + * @fileoverview GitRepositoryLockedError - Thrown when a git lock file exists + */ + +import GitPlumbingError from './GitPlumbingError.js'; + +/** + * Error thrown when a Git operation fails because the repository is locked. + */ +export default class GitRepositoryLockedError extends GitPlumbingError { + /** + * @param {string} message + * @param {string} operation + * @param {Object} [details={}] + */ + constructor(message, operation, details = {}) { + super(message, operation, { + ...details, + code: 'GIT_REPOSITORY_LOCKED', + remediation: 'Another git process is running. If no other process is active, delete .git/index.lock to proceed.', + documentation: 'https://github.com/git-stunts/plumbing/blob/main/docs/RECIPES.md#handling-repository-locks' + }); + this.name = 'GitRepositoryLockedError'; + } +} \ No newline at end of file diff --git a/src/domain/errors/InvalidArgumentError.js b/src/domain/errors/InvalidArgumentError.js new file mode 100644 index 0000000..e70155b --- /dev/null +++ b/src/domain/errors/InvalidArgumentError.js @@ -0,0 +1,20 @@ +/** + * @fileoverview Custom error for invalid arguments + */ + +import GitPlumbingError from './GitPlumbingError.js'; + +/** + * Error thrown when an argument passed to a function is invalid + */ +export default class InvalidArgumentError extends GitPlumbingError { + /** + * @param {string} message + * @param {string} operation + * @param {Object} [details={}] + */ + constructor(message, operation, details = {}) { + super(message, operation, details); + this.name = 'InvalidArgumentError'; + } +} diff --git a/src/domain/errors/InvalidGitObjectTypeError.js b/src/domain/errors/InvalidGitObjectTypeError.js new file mode 100644 index 0000000..1da67b0 --- /dev/null +++ b/src/domain/errors/InvalidGitObjectTypeError.js @@ -0,0 +1,15 @@ +/** + * @fileoverview Custom error types for Git plumbing operations + */ + +import GitPlumbingError from './GitPlumbingError.js'; + +/** + * Error thrown when Git object type validation fails + */ +export default class InvalidGitObjectTypeError extends GitPlumbingError { + constructor(type, operation) { + super(`Invalid Git object type: ${type}`, operation, { type }); + this.name = 'InvalidGitObjectTypeError'; + } +} \ No newline at end of file diff --git a/src/domain/errors/ProhibitedFlagError.js b/src/domain/errors/ProhibitedFlagError.js new file mode 100644 index 0000000..961c5a3 --- /dev/null +++ b/src/domain/errors/ProhibitedFlagError.js @@ -0,0 +1,23 @@ +/** + * @fileoverview Custom error for prohibited git flags + */ + +import GitPlumbingError from './GitPlumbingError.js'; + +/** + * Error thrown when a prohibited git flag is detected + */ +export default class ProhibitedFlagError extends GitPlumbingError { + /** + * @param {string} flag - The prohibited flag detected + * @param {string} operation - The operation being performed + * @param {Object} [details] - Additional details or overrides + * @param {string} [details.message] - Custom error message + */ + constructor(flag, operation, details = {}) { + const defaultMessage = `Prohibited git flag detected: ${flag}. Using flags like --work-tree or --git-dir is forbidden for security and isolation. Please use the 'cwd' option or GitRepositoryService for scoped operations. See README.md for more details.`; + const message = details.message || defaultMessage; + super(message, operation, { flag, ...details }); + this.name = 'ProhibitedFlagError'; + } +} \ No newline at end of file diff --git a/src/domain/errors/ValidationError.js b/src/domain/errors/ValidationError.js new file mode 100644 index 0000000..c9db1ac --- /dev/null +++ b/src/domain/errors/ValidationError.js @@ -0,0 +1,20 @@ +/** + * @fileoverview Custom error for validation failures + */ + +import GitPlumbingError from './GitPlumbingError.js'; + +/** + * Error thrown when validation fails + */ +export default class ValidationError extends GitPlumbingError { + /** + * @param {string} message + * @param {string} operation + * @param {Object} [details={}] + */ + constructor(message, operation, details = {}) { + super(message, operation, details); + this.name = 'ValidationError'; + } +} diff --git a/src/domain/schemas/GitBlobSchema.js b/src/domain/schemas/GitBlobSchema.js new file mode 100644 index 0000000..23334b0 --- /dev/null +++ b/src/domain/schemas/GitBlobSchema.js @@ -0,0 +1,10 @@ +import { z } from 'zod'; +import { GitShaSchema } from './GitShaSchema.js'; + +/** + * Zod schema for GitBlob validation. + */ +export const GitBlobSchema = z.object({ + sha: GitShaSchema.nullable().optional(), + content: z.union([z.string(), z.instanceof(Uint8Array)]) +}); diff --git a/src/domain/schemas/GitCommitSchema.js b/src/domain/schemas/GitCommitSchema.js new file mode 100644 index 0000000..aca86e1 --- /dev/null +++ b/src/domain/schemas/GitCommitSchema.js @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import { GitShaSchema } from './GitShaSchema.js'; +import { GitSignatureSchema } from './GitSignatureSchema.js'; + +/** + * Zod schema for GitCommit validation. + */ +export const GitCommitSchema = z.object({ + sha: GitShaSchema.nullable().optional(), + treeSha: GitShaSchema, // Reference to the tree + parents: z.array(GitShaSchema), + author: GitSignatureSchema, + committer: GitSignatureSchema, + message: z.string() +}); diff --git a/src/domain/schemas/GitFileModeSchema.js b/src/domain/schemas/GitFileModeSchema.js new file mode 100644 index 0000000..011abe0 --- /dev/null +++ b/src/domain/schemas/GitFileModeSchema.js @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +/** + * Valid Git file mode strings. + */ +export const GitFileModeSchema = z.enum([ + '100644', // REGULAR + '100755', // EXECUTABLE + '120000', // SYMLINK + '040000', // TREE + '160000' // COMMIT (Submodule) +]); diff --git a/src/domain/schemas/GitObjectTypeSchema.js b/src/domain/schemas/GitObjectTypeSchema.js new file mode 100644 index 0000000..3f27112 --- /dev/null +++ b/src/domain/schemas/GitObjectTypeSchema.js @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +/** + * Valid Git object type strings. + */ +export const GitObjectTypeStrings = z.enum([ + 'blob', + 'tree', + 'commit', + 'tag', + 'ofs-delta', + 'ref-delta' +]); + +/** + * Valid Git object type integers. + */ +export const GitObjectTypeInts = z.union([ + z.literal(1), // blob + z.literal(2), // tree + z.literal(3), // commit + z.literal(4), // tag + z.literal(6), // ofs-delta + z.literal(7) // ref-delta +]); + +/** + * Zod schema for GitObjectType validation. + */ +export const GitObjectTypeSchema = z.union([GitObjectTypeStrings, GitObjectTypeInts]); diff --git a/src/domain/schemas/GitRefSchema.js b/src/domain/schemas/GitRefSchema.js new file mode 100644 index 0000000..a8a25c3 --- /dev/null +++ b/src/domain/schemas/GitRefSchema.js @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +/** + * Zod schema for GitRef validation. + * Implements core rules of git-check-ref-format. + */ +export const GitRefSchema = z.string() + .min(1) + .refine(val => !val.startsWith('.'), 'Cannot start with a dot') + .refine(val => !val.endsWith('.'), 'Cannot end with a dot') + .refine(val => !val.includes('..'), 'Cannot contain double dots') + .refine(val => !val.includes('/.'), 'Components cannot start with a dot') + .refine(val => !val.includes('//'), 'Cannot contain consecutive slashes') + .refine(val => !val.endsWith('.lock'), 'Cannot end with .lock') + .refine(val => { + // Prohibited characters: space, ~, ^, :, ?, *, [, \ + const prohibited = [' ', '~', '^', ':', '?', '*', '[', '\\']; + return !prohibited.some(char => val.includes(char)); + }, 'Contains prohibited characters') + .refine(val => { + // Control characters (0-31 and 127) + return !Array.from(val).some(char => { + const code = char.charCodeAt(0); + return code < 32 || code === 127; + }); + }, 'Cannot contain control characters') + .refine(val => val !== '@' && !val.includes('@{'), "Cannot be '@' alone or contain '@{'"); \ No newline at end of file diff --git a/src/domain/schemas/GitShaSchema.js b/src/domain/schemas/GitShaSchema.js new file mode 100644 index 0000000..b78bf62 --- /dev/null +++ b/src/domain/schemas/GitShaSchema.js @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +/** + * Zod schema for GitSha validation. + */ +export const GitShaSchema = z.string() + .length(40) + .regex(/^[a-f0-9]+$/i) + .transform(val => val.toLowerCase()); + +/** + * Zod schema for GitSha object structure. + */ +export const GitShaObjectSchema = z.object({ + sha: GitShaSchema +}); diff --git a/src/domain/schemas/GitSignatureSchema.js b/src/domain/schemas/GitSignatureSchema.js new file mode 100644 index 0000000..579528b --- /dev/null +++ b/src/domain/schemas/GitSignatureSchema.js @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +/** + * Zod schema for GitSignature validation. + */ +export const GitSignatureSchema = z.object({ + name: z.string().min(1), + email: z.string().email(), + timestamp: z.number().int().nonnegative().default(() => Math.floor(Date.now() / 1000)) +}); diff --git a/src/domain/schemas/GitTreeEntrySchema.js b/src/domain/schemas/GitTreeEntrySchema.js new file mode 100644 index 0000000..a6931ce --- /dev/null +++ b/src/domain/schemas/GitTreeEntrySchema.js @@ -0,0 +1,12 @@ +import { z } from 'zod'; +import { GitShaSchema } from './GitShaSchema.js'; +import { GitFileModeSchema } from './GitFileModeSchema.js'; + +/** + * Zod schema for GitTreeEntry validation. + */ +export const GitTreeEntrySchema = z.object({ + mode: GitFileModeSchema, + sha: GitShaSchema, + path: z.string().min(1) +}); diff --git a/src/domain/schemas/GitTreeSchema.js b/src/domain/schemas/GitTreeSchema.js new file mode 100644 index 0000000..de22b4f --- /dev/null +++ b/src/domain/schemas/GitTreeSchema.js @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { GitShaSchema } from './GitShaSchema.js'; +import { GitTreeEntrySchema } from './GitTreeEntrySchema.js'; + +/** + * Zod schema for GitTree validation. + */ +export const GitTreeSchema = z.object({ + sha: GitShaSchema.nullable().optional(), + entries: z.array(GitTreeEntrySchema) +}); diff --git a/src/domain/services/ByteMeasurer.js b/src/domain/services/ByteMeasurer.js new file mode 100644 index 0000000..8929e1a --- /dev/null +++ b/src/domain/services/ByteMeasurer.js @@ -0,0 +1,51 @@ +/** + * @fileoverview Domain service for measuring byte size of content + */ + +const ENCODER = new TextEncoder(); + +/** + * Service to measure the byte size of different content types. + * Optimized for Node.js, Bun, and Deno runtimes. + */ +export default class ByteMeasurer { + /** + * Measures the byte length of a string or binary content. + * Optimized for Node.js and other runtimes. + * @param {string|Uint8Array|ArrayBuffer|SharedArrayBuffer|{length: number}} content + * @returns {number} + * @throws {TypeError} If the content type is unsupported. + */ + static measure(content) { + if (content === null || content === undefined) { + throw new TypeError('Content cannot be null or undefined'); + } + + if (typeof content === 'string') { + // Node.js / Bun optimization - fastest way to get UTF-8 byte length without allocation + if (typeof Buffer !== 'undefined' && typeof Buffer.byteLength === 'function') { + return Buffer.byteLength(content, 'utf8'); + } + // Fallback for Deno / Browser + return ENCODER.encode(content).length; + } + + if (content instanceof Uint8Array) { + return content.length; + } + + if (content instanceof ArrayBuffer || (typeof SharedArrayBuffer !== 'undefined' && content instanceof SharedArrayBuffer)) { + return content.byteLength; + } + + if (ArrayBuffer.isView(content)) { + return content.byteLength; + } + + if (typeof content === 'object' && typeof content.length === 'number' && Number.isFinite(content.length)) { + return content.length; + } + + throw new TypeError(`Unsupported content type for ByteMeasurer.measure: ${typeof content}`); + } +} \ No newline at end of file diff --git a/src/domain/services/CommandSanitizer.js b/src/domain/services/CommandSanitizer.js new file mode 100644 index 0000000..e5b6628 --- /dev/null +++ b/src/domain/services/CommandSanitizer.js @@ -0,0 +1,199 @@ +/** + * @fileoverview Domain service for sanitizing git command arguments + */ + +import ValidationError from '../errors/ValidationError.js'; +import ProhibitedFlagError from '../errors/ProhibitedFlagError.js'; + +/** + * Sanitizes and validates git command arguments. + * Implements a defense-in-depth strategy by whitelisting commands, + * blocking dangerous flags, and preventing global flag escapes. + */ +export default class CommandSanitizer { + static MAX_ARGS = 1000; + static MAX_ARG_LENGTH = 8192; + static MAX_TOTAL_LENGTH = 65536; + + /** + * Comprehensive whitelist of allowed git plumbing and essential porcelain commands. + * @private + */ + static _ALLOWED_COMMANDS = new Set([ + 'rev-parse', + 'update-ref', + 'cat-file', + 'hash-object', + 'ls-tree', + 'commit-tree', + 'write-tree', + 'read-tree', + 'rev-list', + 'mktree', + 'unpack-objects', + 'symbolic-ref', + 'for-each-ref', + 'show-ref', + 'diff-tree', + 'diff-index', + 'diff-files', + 'merge-base', + 'ls-files', + 'check-ignore', + 'check-attr', + '--version', + 'init', + 'config' + ]); + + /** + * Flags that are strictly prohibited due to security risks or environment interference. + */ + static PROHIBITED_FLAGS = [ + '--upload-pack', + '--receive-pack', + '--ext-cmd', + '--exec-path', + '--html-path', + '--man-path', + '--info-path', + '--work-tree', + '--git-dir', + '--namespace', + '--template' + ]; + + /** + * Global git flags that are prohibited if they appear before the subcommand. + */ + static GLOBAL_FLAGS = [ + '-C', + '-c', + '--git-dir' + ]; + + /** + * Dynamically allows a command. + * @param {string} commandName + */ + static allow(commandName) { + CommandSanitizer._ALLOWED_COMMANDS.add(commandName.toLowerCase()); + } + + /** + * @param {Object} [options] + * @param {number} [options.maxCacheSize=100] + */ + constructor({ maxCacheSize = 100 } = {}) { + /** @private */ + this._cache = new Map(); + /** @private */ + this._maxCacheSize = maxCacheSize; + } + + /** + * Validates a list of arguments for potential injection or prohibited flags. + * Includes memoization to skip re-validation of repetitive commands. + * @param {string[]} args - The array of git arguments to sanitize. + * @returns {string[]} The validated arguments array. + * @throws {ValidationError|ProhibitedFlagError} If validation fails. + */ + sanitize(args) { + if (!Array.isArray(args)) { + throw new ValidationError('Arguments must be an array', 'CommandSanitizer.sanitize'); + } + + // Simple cache key: joined arguments + const cacheKey = args.join('\0'); + if (this._cache.has(cacheKey)) { + return args; + } + + if (args.length === 0) { + throw new ValidationError('Arguments array cannot be empty', 'CommandSanitizer.sanitize'); + } + + if (args.length > CommandSanitizer.MAX_ARGS) { + throw new ValidationError(`Too many arguments: ${args.length}`, 'CommandSanitizer.sanitize'); + } + + // Find the first non-flag argument to identify the subcommand + let subcommandIndex = -1; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (typeof arg !== 'string') { + throw new ValidationError('Each argument must be a string', 'CommandSanitizer.sanitize', { arg }); + } + if (!arg.startsWith('-')) { + subcommandIndex = i; + break; + } + } + + // Block global flags if they appear before the subcommand + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const lowerArg = arg.toLowerCase(); + + // If we haven't reached the subcommand yet, check for prohibited global flags + if (subcommandIndex === -1 || i < subcommandIndex) { + if (CommandSanitizer.GLOBAL_FLAGS.some(flag => lowerArg === flag.toLowerCase() || lowerArg.startsWith(`${flag.toLowerCase()}=`))) { + throw new ProhibitedFlagError(arg, 'CommandSanitizer.sanitize', { + message: `Global flag "${arg}" is prohibited before the subcommand.` + }); + } + } + } + + // The base command (after global flags) must be in the whitelist + const commandArg = subcommandIndex !== -1 ? args[subcommandIndex] : args[0]; + if (typeof commandArg !== 'string') { + throw new ValidationError('Command must be a string', 'CommandSanitizer.sanitize', { command: commandArg }); + } + + const command = commandArg.toLowerCase(); + if (!CommandSanitizer._ALLOWED_COMMANDS.has(command)) { + throw new ValidationError(`Prohibited git command detected: ${command}`, 'CommandSanitizer.sanitize', { command }); + } + + let totalLength = 0; + for (const arg of args) { + if (typeof arg !== 'string') { + throw new ValidationError('Each argument must be a string', 'CommandSanitizer.sanitize', { arg }); + } + + if (arg.length > CommandSanitizer.MAX_ARG_LENGTH) { + throw new ValidationError(`Argument too long: ${arg.length}`, 'CommandSanitizer.sanitize'); + } + + totalLength += arg.length; + + const lowerArg = arg.toLowerCase(); + + // Strengthen configuration flag blocking + if (lowerArg === '-c' || lowerArg === '--config' || lowerArg.startsWith('--config=')) { + throw new ProhibitedFlagError(arg, 'CommandSanitizer.sanitize'); + } + + // Check for other prohibited flags + for (const prohibited of CommandSanitizer.PROHIBITED_FLAGS) { + if (lowerArg === prohibited || lowerArg.startsWith(`${prohibited}=`)) { + throw new ProhibitedFlagError(arg, 'CommandSanitizer.sanitize'); + } + } + } + + if (totalLength > CommandSanitizer.MAX_TOTAL_LENGTH) { + throw new ValidationError(`Total arguments length too long: ${totalLength}`, 'CommandSanitizer.sanitize'); + } + + // Manage cache size (LRU-ish: delete oldest entry) + if (this._cache.size >= this._maxCacheSize) { + const firstKey = this._cache.keys().next().value; + this._cache.delete(firstKey); + } + this._cache.set(cacheKey, true); + + return args; + } +} diff --git a/src/domain/services/EnvironmentPolicy.js b/src/domain/services/EnvironmentPolicy.js new file mode 100644 index 0000000..3b19d3b --- /dev/null +++ b/src/domain/services/EnvironmentPolicy.js @@ -0,0 +1,69 @@ +/** + * @fileoverview EnvironmentPolicy - Domain service for environment variable security + */ + +/** + * EnvironmentPolicy defines which environment variables are safe to pass + * to the underlying Git process. + * + * It whitelists essential variables for identity and localization while + * explicitly blocking variables that could override security settings. + */ +export default class EnvironmentPolicy { + /** + * List of environment variables allowed to be passed to the git process. + * Whitelists identity (GIT_AUTHOR_*, GIT_COMMITTER_*) and localization (LANG, LC_ALL). + * @private + */ + static _ALLOWED_KEYS = [ + 'PATH', + 'GIT_EXEC_PATH', + 'GIT_TEMPLATE_DIR', + 'GIT_CONFIG_NOSYSTEM', + 'GIT_ATTR_NOSYSTEM', + // Identity + 'GIT_AUTHOR_NAME', + 'GIT_AUTHOR_EMAIL', + 'GIT_AUTHOR_DATE', + 'GIT_AUTHOR_TZ', + 'GIT_COMMITTER_NAME', + 'GIT_COMMITTER_EMAIL', + 'GIT_COMMITTER_DATE', + 'GIT_COMMITTER_TZ', + // Localization & Encoding + 'LANG', + 'LC_ALL', + 'LC_CTYPE', + 'LC_MESSAGES' + ]; + + /** + * List of environment variables that are explicitly blocked. + * @private + */ + static _BLOCKED_KEYS = [ + 'GIT_CONFIG_PARAMETERS' + ]; + + /** + * Filters the provided environment object based on the whitelist and blacklist. + * @param {Object} env - The source environment object (e.g., process.env). + * @returns {Object} A sanitized environment object. + */ + static filter(env = {}) { + const sanitized = {}; + + for (const key of EnvironmentPolicy._ALLOWED_KEYS) { + // Ensure we don't allow a key if it's also in the blocked list (redundancy) + if (EnvironmentPolicy._BLOCKED_KEYS.includes(key)) { + continue; + } + + if (env[key] !== undefined) { + sanitized[key] = env[key]; + } + } + + return sanitized; + } +} \ No newline at end of file diff --git a/src/domain/services/ExecutionOrchestrator.js b/src/domain/services/ExecutionOrchestrator.js new file mode 100644 index 0000000..12b7aae --- /dev/null +++ b/src/domain/services/ExecutionOrchestrator.js @@ -0,0 +1,113 @@ +/** + * @fileoverview ExecutionOrchestrator - Domain service for command execution lifecycle + */ + +import GitErrorClassifier from './GitErrorClassifier.js'; +import GitPlumbingError from '../errors/GitPlumbingError.js'; + +/** + * ExecutionOrchestrator manages the retry and failure detection logic for Git commands. + * Implements a "Total Operation Timeout" to prevent infinite retry loops. + */ +export default class ExecutionOrchestrator { + /** + * @param {Object} [options] + * @param {GitErrorClassifier} [options.classifier] + */ + constructor({ classifier = new GitErrorClassifier() } = {}) { + /** @private */ + this.classifier = classifier; + } + + /** + * Orchestrates the execution of a command with retry and lock detection. + * @param {Object} options + * @param {Function} options.execute - Async function that performs a single execution attempt. + * @param {import('../value-objects/CommandRetryPolicy.js').default} options.retryPolicy + * @param {string[]} options.args + * @param {string} options.traceId + * @returns {Promise} + */ + async orchestrate({ execute, retryPolicy, args, traceId }) { + const operationStartTime = performance.now(); + let attempt = 0; + + while (attempt < retryPolicy.maxAttempts) { + const startTime = performance.now(); + attempt++; + + // 1. Check for total operation timeout before starting attempt + this._checkTotalTimeout(operationStartTime, retryPolicy.totalTimeout, args, traceId); + + try { + const { stdout, result } = await execute(); + const latency = performance.now() - startTime; + + // 2. Check for total operation timeout after execute() completes + this._checkTotalTimeout(operationStartTime, retryPolicy.totalTimeout, args, traceId); + + if (result.code !== 0) { + const error = this.classifier.classify({ + code: result.code, + stderr: result.stderr, + args, + stdout, + traceId, + latency, + operation: 'ExecutionOrchestrator.orchestrate' + }); + + if (this.classifier.isRetryable(error) && attempt < retryPolicy.maxAttempts) { + const backoff = retryPolicy.getDelay(attempt); + + // Re-check if we have time for backoff + next attempt + if (retryPolicy.totalTimeout && (performance.now() - operationStartTime + backoff) > retryPolicy.totalTimeout) { + throw error; // Not enough time left for backoff + } + + await new Promise(resolve => setTimeout(resolve, backoff)); + continue; + } + + throw error; + } + + return stdout.trim(); + } catch (err) { + // Rethrow classified GitPlumbingErrors, wrap others + if (err instanceof GitPlumbingError) { + throw err; + } + throw new GitPlumbingError(err.message, 'ExecutionOrchestrator.orchestrate', { + args, + traceId, + originalError: err + }); + } + } + + throw new GitPlumbingError('All retry attempts exhausted', 'ExecutionOrchestrator.orchestrate', { + args, + traceId, + attempt, + retryPolicy + }); + } + + /** + * Helper to verify if total operation timeout has been exceeded. + * @private + */ + _checkTotalTimeout(startTime, totalTimeout, args, traceId) { + if (!totalTimeout) {return;} + + const elapsedTotal = performance.now() - startTime; + if (elapsedTotal > totalTimeout) { + throw new GitPlumbingError( + `Total operation timeout exceeded after ${Math.round(elapsedTotal)}ms`, + 'ExecutionOrchestrator.orchestrate', + { args, traceId, elapsedTotal, totalTimeout } + ); + } + } +} \ No newline at end of file diff --git a/src/domain/services/GitBinaryChecker.js b/src/domain/services/GitBinaryChecker.js new file mode 100644 index 0000000..ede72d4 --- /dev/null +++ b/src/domain/services/GitBinaryChecker.js @@ -0,0 +1,56 @@ +/** + * @fileoverview GitBinaryChecker - Domain service for verifying Git availability + */ + +import GitPlumbingError from '../errors/GitPlumbingError.js'; + +/** + * Service to verify that the Git binary is installed and functional. + */ +export default class GitBinaryChecker { + /** + * @param {Object} options + * @param {import('../../../index.js').default} options.plumbing - The plumbing service for execution. + */ + constructor({ plumbing }) { + /** @private */ + this.plumbing = plumbing; + } + + /** + * Verifies that the git binary is available. + * @returns {Promise} + * @throws {GitPlumbingError} + */ + async check() { + try { + // Check binary availability by calling --version + await this.plumbing.execute({ args: ['--version'] }); + return true; + } catch (err) { + throw new GitPlumbingError( + `Git binary verification failed: ${err.message}`, + 'GitBinaryChecker.check', + { originalError: err.message, code: 'GIT_BINARY_NOT_FOUND' } + ); + } + } + + /** + * Checks if the current working directory is inside a Git repository. + * @returns {Promise} + * @throws {GitPlumbingError} + */ + async isInsideWorkTree() { + try { + const isInside = await this.plumbing.execute({ args: ['rev-parse', '--is-inside-work-tree'] }); + return isInside === 'true'; + } catch (err) { + throw new GitPlumbingError( + `Git repository verification failed: ${err.message}`, + 'GitBinaryChecker.isInsideWorkTree', + { originalError: err.message, code: 'GIT_NOT_IN_REPO' } + ); + } + } +} diff --git a/src/domain/services/GitCommandBuilder.js b/src/domain/services/GitCommandBuilder.js new file mode 100644 index 0000000..a7f8bde --- /dev/null +++ b/src/domain/services/GitCommandBuilder.js @@ -0,0 +1,176 @@ +/** + * @fileoverview Domain service for building git command arguments + */ + +/** + * Fluent builder for git command arguments. + * Provides a type-safe and expressive API for constructing Git plumbing commands. + */ +export default class GitCommandBuilder { + /** + * @param {string} command - The git plumbing command (e.g., 'update-ref') + */ + constructor(command) { + this._command = command; + this._args = [command]; + } + + // --- Static Factory Methods --- + + static revParse() { return new GitCommandBuilder('rev-parse'); } + static updateRef() { return new GitCommandBuilder('update-ref'); } + static catFile() { return new GitCommandBuilder('cat-file'); } + static hashObject() { return new GitCommandBuilder('hash-object'); } + static lsTree() { return new GitCommandBuilder('ls-tree'); } + static commitTree() { return new GitCommandBuilder('commit-tree'); } + static writeTree() { return new GitCommandBuilder('write-tree'); } + static readTree() { return new GitCommandBuilder('read-tree'); } + static revList() { return new GitCommandBuilder('rev-list'); } + static mktree() { return new GitCommandBuilder('mktree'); } + static unpackObjects() { return new GitCommandBuilder('unpack-objects'); } + static symbolicRef() { return new GitCommandBuilder('symbolic-ref'); } + static forEachRef() { return new GitCommandBuilder('for-each-ref'); } + static showRef() { return new GitCommandBuilder('show-ref'); } + static diffTree() { return new GitCommandBuilder('diff-tree'); } + static diffIndex() { return new GitCommandBuilder('diff-index'); } + static diffFiles() { return new GitCommandBuilder('diff-files'); } + static mergeBase() { return new GitCommandBuilder('merge-base'); } + static lsFiles() { return new GitCommandBuilder('ls-files'); } + static checkIgnore() { return new GitCommandBuilder('check-ignore'); } + static checkAttr() { return new GitCommandBuilder('check-attr'); } + static version() { return new GitCommandBuilder('--version'); } + static init() { return new GitCommandBuilder('init'); } + static config() { return new GitCommandBuilder('config'); } + + // --- Fluent flag methods --- + + /** + * Adds the --stdin flag + * @returns {GitCommandBuilder} + */ + stdin() { + this._args.push('--stdin'); + return this; + } + + /** + * Adds the -w flag (write) + * @returns {GitCommandBuilder} + */ + write() { + this._args.push('-w'); + return this; + } + + /** + * Adds the -p flag (pretty-print) + * @returns {GitCommandBuilder} + */ + pretty() { + this._args.push('-p'); + return this; + } + + /** + * Adds the -t flag (type) + * @returns {GitCommandBuilder} + */ + type() { + this._args.push('-t'); + return this; + } + + /** + * Adds the -s flag (size) + * @returns {GitCommandBuilder} + */ + size() { + this._args.push('-s'); + return this; + } + + /** + * Adds the -m flag (message) + * @param {string} msg + * @returns {GitCommandBuilder} + */ + message(msg) { + this._args.push('-m', msg); + return this; + } + + /** + * Adds the -p flag (parent) - Note: shared with pretty-print in some commands + * @param {string} sha + * @returns {GitCommandBuilder} + */ + parent(sha) { + this._args.push('-p', sha); + return this; + } + + /** + * Adds the -d flag (delete) + * @returns {GitCommandBuilder} + */ + delete() { + this._args.push('-d'); + return this; + } + + /** + * Adds the -z flag (NUL-terminated output) + * @returns {GitCommandBuilder} + */ + nul() { + this._args.push('-z'); + return this; + } + + /** + * Adds the --batch flag + * @returns {GitCommandBuilder} + */ + batch() { + this._args.push('--batch'); + return this; + } + + /** + * Adds the --batch-check flag + * @returns {GitCommandBuilder} + */ + batchCheck() { + this._args.push('--batch-check'); + return this; + } + + /** + * Adds the --all flag + * @returns {GitCommandBuilder} + */ + all() { + this._args.push('--all'); + return this; + } + + /** + * Adds a positional argument to the command. + * @param {string|number|null|undefined} arg - The argument to add. + * @returns {GitCommandBuilder} This builder instance for chaining. + */ + arg(arg) { + if (arg !== undefined && arg !== null) { + this._args.push(String(arg)); + } + return this; + } + + /** + * Builds the arguments array + * @returns {string[]} + */ + build() { + return [...this._args]; + } +} diff --git a/src/domain/services/GitErrorClassifier.js b/src/domain/services/GitErrorClassifier.js new file mode 100644 index 0000000..141a4ec --- /dev/null +++ b/src/domain/services/GitErrorClassifier.js @@ -0,0 +1,74 @@ +/** + * @fileoverview GitErrorClassifier - Domain service for categorizing Git errors + */ + +import GitPlumbingError from '../errors/GitPlumbingError.js'; +import GitRepositoryLockedError from '../errors/GitRepositoryLockedError.js'; + +/** + * Classifies Git errors based on exit codes and stderr patterns. + */ +export default class GitErrorClassifier { + /** + * @param {Object} [options] + * @param {Array<{test: function(number, string): boolean, create: function(Object): Error}>} [options.customRules=[]] + */ + constructor({ customRules = [] } = {}) { + /** @private */ + this.customRules = customRules; + } + + /** + * Classifies a Git command failure. + * @param {Object} options + * @param {number} options.code + * @param {string} options.stderr + * @param {string[]} options.args + * @param {string} [options.stdout] + * @param {string} options.traceId + * @param {number} options.latency + * @param {string} options.operation + * @returns {GitPlumbingError} + */ + classify({ code, stderr, args, stdout, traceId, latency, operation }) { + // 1. Check custom rules first + for (const rule of this.customRules) { + if (rule.test(code, stderr)) { + return rule.create({ code, stderr, args, stdout, traceId, latency, operation }); + } + } + + // 2. Check for lock contention (Exit code 128 indicates state/lock issues) + // Use regex for more robust detection of lock files (index.lock or other .lock files) + const lockRegex = /\w+\.lock/; + const isLocked = code === 128 && (lockRegex.test(stderr) || stderr.includes('lock')); + + if (isLocked) { + return new GitRepositoryLockedError(`Git command failed: repository is locked`, operation, { + args, + stderr, + code, + traceId, + latency + }); + } + + return new GitPlumbingError(`Git command failed with code ${code}`, operation, { + args, + stderr, + stdout, + code, + traceId, + latency + }); + } + + /** + * Checks if an error is retryable (e.g., lock contention). + * @param {Error} err + * @returns {boolean} + */ + isRetryable(err) { + return err instanceof GitRepositoryLockedError; + } +} \ No newline at end of file diff --git a/src/domain/services/GitPersistenceService.js b/src/domain/services/GitPersistenceService.js new file mode 100644 index 0000000..5c786d4 --- /dev/null +++ b/src/domain/services/GitPersistenceService.js @@ -0,0 +1,120 @@ +/** + * @fileoverview GitPersistenceService - Domain service for Git object persistence + */ + +import GitSha from '../value-objects/GitSha.js'; +import GitCommandBuilder from './GitCommandBuilder.js'; +import GitBlob from '../entities/GitBlob.js'; +import GitTree from '../entities/GitTree.js'; +import GitCommit from '../entities/GitCommit.js'; +import InvalidArgumentError from '../errors/InvalidArgumentError.js'; +import EnvironmentPolicy from './EnvironmentPolicy.js'; + +/** + * GitPersistenceService implements the persistence logic for Git entities. + */ +export default class GitPersistenceService { + /** + * @param {Object} options + * @param {import('../../../index.js').default} options.plumbing - The plumbing service for execution. + */ + constructor({ plumbing }) { + this.plumbing = plumbing; + } + + /** + * Persists a Git entity (Blob, Tree, or Commit). + * @param {GitBlob|GitTree|GitCommit} entity + * @returns {Promise} + */ + async persist(entity) { + if (entity instanceof GitBlob) { + return await this.writeBlob(entity); + } else if (entity instanceof GitTree) { + return await this.writeTree(entity); + } else if (entity instanceof GitCommit) { + return await this.writeCommit(entity); + } + throw new InvalidArgumentError('Unsupported entity type for persistence', 'GitPersistenceService.persist'); + } + + /** + * Persists a GitBlob to the object database. + * @param {GitBlob} blob + * @returns {Promise} + */ + async writeBlob(blob) { + if (!(blob instanceof GitBlob)) { + throw new InvalidArgumentError('Expected instance of GitBlob', 'GitPersistenceService.writeBlob'); + } + + const args = GitCommandBuilder.hashObject() + .write() + .stdin() + .build(); + + const shaStr = await this.plumbing.execute({ + args, + input: blob.content + }); + + return GitSha.from(shaStr.trim()); + } + + /** + * Persists a GitTree to the object database. + * @param {GitTree} tree + * @returns {Promise} + */ + async writeTree(tree) { + if (!(tree instanceof GitTree)) { + throw new InvalidArgumentError('Expected instance of GitTree', 'GitPersistenceService.writeTree'); + } + + const input = tree.toMktreeFormat(); + const args = GitCommandBuilder.mktree().build(); + + const shaStr = await this.plumbing.execute({ + args, + input + }); + + return GitSha.from(shaStr.trim()); + } + + /** + * Persists a GitCommit to the object database. + * @param {GitCommit} commit + * @returns {Promise} + */ + async writeCommit(commit) { + if (!(commit instanceof GitCommit)) { + throw new InvalidArgumentError('Expected instance of GitCommit', 'GitPersistenceService.writeCommit'); + } + + const builder = GitCommandBuilder.commitTree() + .arg(commit.treeSha.toString()); + + for (const parent of commit.parents) { + builder.parent(parent.toString()); + } + + builder.message(commit.message); + + const args = builder.build(); + + // Ensure environment is filtered through policy + const env = EnvironmentPolicy.filter({ + GIT_AUTHOR_NAME: commit.author.name, + GIT_AUTHOR_EMAIL: commit.author.email, + GIT_AUTHOR_DATE: `${commit.author.timestamp} +0000`, + GIT_COMMITTER_NAME: commit.committer.name, + GIT_COMMITTER_EMAIL: commit.committer.email, + GIT_COMMITTER_DATE: `${commit.committer.timestamp} +0000` + }); + + const shaStr = await this.plumbing.execute({ args, env }); + + return GitSha.from(shaStr.trim()); + } +} diff --git a/src/domain/services/GitRepositoryService.js b/src/domain/services/GitRepositoryService.js new file mode 100644 index 0000000..4e1eb20 --- /dev/null +++ b/src/domain/services/GitRepositoryService.js @@ -0,0 +1,172 @@ +/** + * @fileoverview GitRepositoryService - High-level domain service for repository operations + */ + +import GitSha from '../value-objects/GitSha.js'; +import GitCommandBuilder from './GitCommandBuilder.js'; +import GitPersistenceService from './GitPersistenceService.js'; +import GitBlob from '../entities/GitBlob.js'; +import GitTree from '../entities/GitTree.js'; +import GitTreeEntry from '../entities/GitTreeEntry.js'; +import GitCommit from '../entities/GitCommit.js'; + +/** + * GitRepositoryService provides high-level operations on a Git repository. + * It uses a CommandRunner port via GitPlumbing to execute commands. + */ +export default class GitRepositoryService { + /** + * @param {Object} options + * @param {import('../../../index.js').default} options.plumbing - The plumbing service for execution. + * @param {GitPersistenceService} [options.persistence] - Injected persistence service. + */ + constructor({ plumbing, persistence = new GitPersistenceService({ plumbing }) }) { + this.plumbing = plumbing; + this.persistence = persistence; + } + + /** + * Orchestrates a full commit sequence from files and metadata. + * Uses a concurrency limit to prevent resource exhaustion during blob creation. + * @param {Object} options + * @param {string} options.branch - The reference to update (e.g., 'refs/heads/main') + * @param {string} options.message - Commit message + * @param {import('../value-objects/GitSignature.js').default} options.author + * @param {import('../value-objects/GitSignature.js').default} options.committer + * @param {import('../value-objects/GitSha.js').default[]} options.parents + * @param {Array<{path: string, content: string|Uint8Array, mode: string}>} options.files + * @param {number} [options.concurrency=10] - Max parallel blob write operations. + * @returns {Promise} The resulting commit SHA. + */ + async createCommitFromFiles({ + branch, + message, + author, + committer, + parents, + files, + concurrency = 10 + }) { + const entries = []; + const remainingFiles = [...files]; + + // Concurrency limit for writing blobs + const processBatch = async () => { + const batch = remainingFiles.splice(0, concurrency); + if (batch.length === 0) {return;} + + const batchResults = await Promise.all(batch.map(async (file) => { + const blob = GitBlob.fromContent(file.content); + const sha = await this.writeBlob(blob); + return new GitTreeEntry({ + path: file.path, + sha, + mode: file.mode || '100644' + }); + })); + + entries.push(...batchResults); + await processBatch(); + }; + + await processBatch(); + + // 2. Write Tree + const tree = new GitTree(null, entries); + const treeSha = await this.writeTree(tree); + + // 3. Write Commit + const commit = new GitCommit({ + sha: null, + treeSha, + parents, + author, + committer, + message + }); + const commitSha = await this.writeCommit(commit); + + // 4. Update Reference + if (branch) { + await this.updateRef({ ref: branch, newSha: commitSha }); + } + + return commitSha; + } + + /** + * Persists any Git entity (Blob, Tree, or Commit) and returns its SHA. + * @param {import('../entities/GitBlob.js').default|import('../entities/GitTree.js').default|import('../entities/GitCommit.js').default} entity + * @returns {Promise} + */ + async save(entity) { + return await this.persistence.persist(entity); + } + + /** + * Persists a blob. + * @param {import('../entities/GitBlob.js').default} blob + * @returns {Promise} + */ + async writeBlob(blob) { + return await this.persistence.writeBlob(blob); + } + + /** + * Persists a tree. + * @param {import('../entities/GitTree.js').default} tree + * @returns {Promise} + */ + async writeTree(tree) { + return await this.persistence.writeTree(tree); + } + + /** + * Persists a commit. + * @param {import('../entities/GitCommit.js').default} commit + * @returns {Promise} + */ + async writeCommit(commit) { + return await this.persistence.writeCommit(commit); + } + + /** + * Resolves a revision to a full SHA. + * @param {Object} options + * @param {string} options.revision + * @returns {Promise} + */ + async revParse({ revision }) { + const args = GitCommandBuilder.revParse().arg(revision).build(); + return await this.plumbing.execute({ args }); + } + + /** + * Updates a reference to point to a new SHA. + * @param {Object} options + * @param {string} options.ref + * @param {import('../value-objects/GitSha.js').default|string} options.newSha + * @param {import('../value-objects/GitSha.js').default|string} [options.oldSha] + */ + async updateRef({ ref, newSha, oldSha }) { + const gitNewSha = newSha instanceof GitSha ? newSha : GitSha.from(newSha); + const gitOldSha = oldSha ? (oldSha instanceof GitSha ? oldSha : GitSha.from(oldSha)) : null; + + const args = GitCommandBuilder.updateRef() + .arg(ref) + .arg(gitNewSha.toString()) + .arg(gitOldSha ? gitOldSha.toString() : null) + .build(); + await this.plumbing.execute({ args }); + } + + /** + * Deletes a reference. + * @param {Object} options + * @param {string} options.ref + */ + async deleteRef({ ref }) { + const args = GitCommandBuilder.updateRef().delete().arg(ref).build(); + await this.plumbing.execute({ args }); + } +} \ No newline at end of file diff --git a/src/domain/value-objects/CommandRetryPolicy.js b/src/domain/value-objects/CommandRetryPolicy.js new file mode 100644 index 0000000..1327bee --- /dev/null +++ b/src/domain/value-objects/CommandRetryPolicy.js @@ -0,0 +1,69 @@ +/** + * @fileoverview CommandRetryPolicy - Value object for retry logic configuration + */ + +import InvalidArgumentError from '../errors/InvalidArgumentError.js'; + +/** + * Encapsulates the strategy for retrying failed commands. + */ +export default class CommandRetryPolicy { + /** + * @param {Object} options + * @param {number} [options.maxAttempts=3] + * @param {number} [options.initialDelayMs=100] + * @param {number} [options.backoffFactor=2] + * @param {number} [options.totalTimeout=30000] - Total timeout for all attempts in ms. + */ + constructor({ maxAttempts = 3, initialDelayMs = 100, backoffFactor = 2, totalTimeout = 30000 } = {}) { + if (maxAttempts < 1) { + throw new InvalidArgumentError('maxAttempts must be at least 1', 'CommandRetryPolicy.constructor'); + } + + this.maxAttempts = maxAttempts; + this.initialDelayMs = initialDelayMs; + this.backoffFactor = backoffFactor; + this.totalTimeout = totalTimeout; + } + + /** + * Calculates the delay for a given attempt. + * @param {number} attempt - 1-based attempt number. + * @returns {number} Delay in milliseconds. + */ + getDelay(attempt) { + if (attempt <= 1) { + return 0; + } + return Math.pow(this.backoffFactor, attempt - 1) * this.initialDelayMs; + } + + /** + * Creates a default policy. + * @returns {CommandRetryPolicy} + */ + static default() { + return new CommandRetryPolicy(); + } + + /** + * Creates a policy with no retries. + * @returns {CommandRetryPolicy} + */ + static none() { + return new CommandRetryPolicy({ maxAttempts: 1 }); + } + + /** + * Returns a JSON representation. + * @returns {Object} + */ + toJSON() { + return { + maxAttempts: this.maxAttempts, + initialDelayMs: this.initialDelayMs, + backoffFactor: this.backoffFactor, + totalTimeout: this.totalTimeout + }; + } +} \ No newline at end of file diff --git a/src/domain/value-objects/GitFileMode.js b/src/domain/value-objects/GitFileMode.js new file mode 100644 index 0000000..55a3952 --- /dev/null +++ b/src/domain/value-objects/GitFileMode.js @@ -0,0 +1,90 @@ +/** + * @fileoverview GitFileMode value object - represents Git file modes + */ + +import GitObjectType from './GitObjectType.js'; +import ValidationError from '../errors/ValidationError.js'; + +/** + * Represents a Git file mode + */ +export default class GitFileMode { + static REGULAR = '100644'; + static EXECUTABLE = '100755'; + static SYMLINK = '120000'; + static TREE = '040000'; + static COMMIT = '160000'; // Submodule + + static VALID_MODES = [ + GitFileMode.REGULAR, + GitFileMode.EXECUTABLE, + GitFileMode.SYMLINK, + GitFileMode.TREE, + GitFileMode.COMMIT + ]; + + /** + * @param {string} mode + */ + constructor(mode) { + if (!GitFileMode.isValid(mode)) { + throw new ValidationError(`Invalid Git file mode: ${mode}`, 'GitFileMode.constructor', { mode }); + } + this._value = mode; + } + + /** + * Validates if a string is a valid Git file mode + * @param {string} mode + * @returns {boolean} + */ + static isValid(mode) { + return GitFileMode.VALID_MODES.includes(mode); + } + + /** + * Returns the mode as a string + * @returns {string} + */ + toString() { + return this._value; + } + + /** + * Returns the corresponding GitObjectType + * @returns {GitObjectType} + */ + getObjectType() { + if (this.isTree()) { + return GitObjectType.tree(); + } + if (this._value === GitFileMode.COMMIT) { + return GitObjectType.commit(); + } + return GitObjectType.blob(); + } + + /** + * Checks if this is a directory (tree) + * @returns {boolean} + */ + isTree() { + return this._value === GitFileMode.TREE; + } + + /** + * Checks if this is a regular file + * @returns {boolean} + */ + isRegular() { + return this._value === GitFileMode.REGULAR; + } + + /** + * Checks if this is an executable file + * @returns {boolean} + */ + isExecutable() { + return this._value === GitFileMode.EXECUTABLE; + } +} diff --git a/src/domain/value-objects/GitObjectType.js b/src/domain/value-objects/GitObjectType.js new file mode 100644 index 0000000..e8fefd2 --- /dev/null +++ b/src/domain/value-objects/GitObjectType.js @@ -0,0 +1,148 @@ +/** + * @fileoverview GitObjectType value object - represents Git object types + */ + +import InvalidGitObjectTypeError from '../errors/InvalidGitObjectTypeError.js'; + +/** + * Represents a Git object type + */ +export default class GitObjectType { + static BLOB_INT = 1; + static TREE_INT = 2; + static COMMIT_INT = 3; + static TAG_INT = 4; + static OFS_DELTA_INT = 6; + static REF_DELTA_INT = 7; + + static BLOB = 'blob'; + static TREE = 'tree'; + static COMMIT = 'commit'; + static TAG = 'tag'; + static OFS_DELTA = 'ofs-delta'; + static REF_DELTA = 'ref-delta'; + + static TYPE_MAP = { + [GitObjectType.BLOB_INT]: GitObjectType.BLOB, + [GitObjectType.TREE_INT]: GitObjectType.TREE, + [GitObjectType.COMMIT_INT]: GitObjectType.COMMIT, + [GitObjectType.TAG_INT]: GitObjectType.TAG, + [GitObjectType.OFS_DELTA_INT]: GitObjectType.OFS_DELTA, + [GitObjectType.REF_DELTA_INT]: GitObjectType.REF_DELTA + }; + + static STRING_TO_INT = { + [GitObjectType.BLOB]: GitObjectType.BLOB_INT, + [GitObjectType.TREE]: GitObjectType.TREE_INT, + [GitObjectType.COMMIT]: GitObjectType.COMMIT_INT, + [GitObjectType.TAG]: GitObjectType.TAG_INT, + [GitObjectType.OFS_DELTA]: GitObjectType.OFS_DELTA_INT, + [GitObjectType.REF_DELTA]: GitObjectType.REF_DELTA_INT + }; + + /** + * @param {number} typeInt - The integer representation of the Git object type. + */ + constructor(typeInt) { + if (GitObjectType.TYPE_MAP[typeInt] === undefined) { + throw new InvalidGitObjectTypeError(typeInt); + } + this._value = typeInt; + } + + /** + * Creates a GitObjectType from a string name. + * @param {string} typeName - The string name (e.g., 'blob', 'tree'). + * @returns {GitObjectType} + */ + static fromString(typeName) { + const typeInt = GitObjectType.STRING_TO_INT[typeName]; + if (typeInt === undefined) { + throw new InvalidGitObjectTypeError(typeName); + } + return new GitObjectType(typeInt); + } + + /** + * Returns if the type is valid + * @param {number} typeInt + * @returns {boolean} + */ + static isValid(typeInt) { + return GitObjectType.TYPE_MAP[typeInt] !== undefined; + } + + /** + * Returns the integer representation + * @returns {number} + */ + toNumber() { + return this._value; + } + + /** + * Returns the string representation + * @returns {string} + */ + toString() { + return GitObjectType.TYPE_MAP[this._value]; + } + + /** + * Returns the string representation (for JSON serialization) + * @returns {string} + */ + toJSON() { + return this.toString(); + } + + /** + * Checks equality with another GitObjectType + * @param {GitObjectType} other + * @returns {boolean} + */ + equals(other) { + if (!(other instanceof GitObjectType)) {return false;} + return this._value === other._value; + } + + /** + * Returns if this is a blob + * @returns {boolean} + */ + isBlob() { + return this._value === GitObjectType.BLOB_INT; + } + + /** + * Returns if this is a tree + * @returns {boolean} + */ + isTree() { + return this._value === GitObjectType.TREE_INT; + } + + /** + * Returns if this is a commit + * @returns {boolean} + */ + isCommit() { + return this._value === GitObjectType.COMMIT_INT; + } + + /** + * Returns if this is a tag + * @returns {boolean} + */ + isTag() { + return this._value === GitObjectType.TAG_INT; + } + + /** + * Static factory methods + */ + static blob() { return new GitObjectType(GitObjectType.BLOB_INT); } + static tree() { return new GitObjectType(GitObjectType.TREE_INT); } + static commit() { return new GitObjectType(GitObjectType.COMMIT_INT); } + static tag() { return new GitObjectType(GitObjectType.TAG_INT); } +} \ No newline at end of file diff --git a/src/domain/value-objects/GitRef.js b/src/domain/value-objects/GitRef.js new file mode 100644 index 0000000..f411cec --- /dev/null +++ b/src/domain/value-objects/GitRef.js @@ -0,0 +1,154 @@ +/** + * @fileoverview GitRef value object - immutable Git reference with validation + */ + +import ValidationError from '../errors/ValidationError.js'; +import { GitRefSchema } from '../schemas/GitRefSchema.js'; + +/** + * GitRef represents a Git reference with validation. + * References must be valid Git ref names. + */ +export default class GitRef { + static PREFIX_HEADS = 'refs/heads/'; + static PREFIX_TAGS = 'refs/tags/'; + static PREFIX_REMOTES = 'refs/remotes/'; + + /** + * @param {string} ref - The Git reference string + */ + constructor(ref) { + const result = GitRefSchema.safeParse(ref); + if (!result.success) { + throw new ValidationError( + `Invalid Git reference: ${ref}. Reason: ${result.error.errors[0].message}`, + 'GitRef.constructor', + { ref, errors: result.error.errors } + ); + } + this._value = result.data; + } + + /** + * Validates if a string is a valid Git reference + * @param {string} ref + * @returns {boolean} + */ + static isValid(ref) { + return GitRefSchema.safeParse(ref).success; + } + + /** + * Creates a GitRef from a string, throwing if invalid + * @param {string} ref + * @returns {GitRef} + */ + static fromString(ref) { + return new GitRef(ref); + } + + /** + * Creates a GitRef from a string, returning null if invalid + * @param {string} ref + * @returns {GitRef|null} + */ + static fromStringOrNull(ref) { + if (!this.isValid(ref)) { return null; } + return new GitRef(ref); + } + + /** + * Returns the Git reference as a string + * @returns {string} + */ + toString() { + return this._value; + } + + /** + * Returns the Git reference as a string (for JSON serialization) + * @returns {string} + */ + toJSON() { + return this._value; + } + + /** + * Checks equality with another GitRef + * @param {GitRef} other + * @returns {boolean} + */ + equals(other) { + if (!(other instanceof GitRef)) { return false; } + return this._value === other._value; + } + + /** + * Checks if this is a branch reference + * @returns {boolean} + */ + isBranch() { + return this._value.startsWith(GitRef.PREFIX_HEADS); + } + + /** + * Checks if this is a tag reference + * @returns {boolean} + */ + isTag() { + return this._value.startsWith(GitRef.PREFIX_TAGS); + } + + /** + * Checks if this is a remote reference + * @returns {boolean} + */ + isRemote() { + return this._value.startsWith(GitRef.PREFIX_REMOTES); + } + + /** + * Gets the short name of the reference (without refs/heads/ prefix) + * @returns {string} + */ + shortName() { + if (this.isBranch()) { + return this._value.substring(GitRef.PREFIX_HEADS.length); + } + if (this.isTag()) { + return this._value.substring(GitRef.PREFIX_TAGS.length); + } + if (this.isRemote()) { + return this._value.substring(GitRef.PREFIX_REMOTES.length); + } + return this._value; + } + + /** + * Creates a branch reference + * @param {string} name + * @returns {GitRef} + */ + static branch(name) { + return new GitRef(`${GitRef.PREFIX_HEADS}${name}`); + } + + /** + * Creates a tag reference + * @param {string} name + * @returns {GitRef} + */ + static tag(name) { + return new GitRef(`${GitRef.PREFIX_TAGS}${name}`); + } + + /** + * Creates a remote reference + * @param {string} remote + * @param {string} name + * @returns {GitRef} + */ + static remote(remote, name) { + return new GitRef(`${GitRef.PREFIX_REMOTES}${remote}/${name}`); + } +} \ No newline at end of file diff --git a/src/domain/value-objects/GitSha.js b/src/domain/value-objects/GitSha.js new file mode 100644 index 0000000..dd93c6a --- /dev/null +++ b/src/domain/value-objects/GitSha.js @@ -0,0 +1,95 @@ +/** + * @fileoverview GitSha value object - immutable SHA-1 hash with validation + */ + +import ValidationError from '../errors/ValidationError.js'; +import { GitShaSchema } from '../schemas/GitShaSchema.js'; + +/** + * GitSha represents a Git SHA-1 hash with validation. + * SHA-1 hashes are always 40 characters long and contain only hexadecimal characters. + */ +export default class GitSha { + static LENGTH = 40; + static SHORT_LENGTH = 7; + static EMPTY_TREE_VALUE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; + + /** + * @param {string} sha - The SHA-1 hash string + */ + constructor(sha) { + this._value = sha; + } + + /** + * Creates a GitSha from a string, throwing if invalid. + * Consolidates validation into a single entry point. + * @param {string} sha + * @returns {GitSha} + * @throws {ValidationError} + */ + static from(sha) { + const result = GitShaSchema.safeParse(sha); + if (!result.success) { + throw new ValidationError( + `Invalid SHA-1 hash: "${sha}". Must be a 40-character hexadecimal string.`, + 'GitSha.from', + { + sha, + helpUrl: 'https://git-scm.com/book/en/v2/Git-Internals-Git-Objects' + } + ); + } + return new GitSha(result.data); + } + + /** + * Returns the SHA-1 hash as a string + * @returns {string} + */ + toString() { + return this._value; + } + + /** + * Returns the SHA-1 hash as a string (for JSON serialization) + * @returns {string} + */ + toJSON() { + return this._value; + } + + /** + * Checks equality with another GitSha + * @param {GitSha} other + * @returns {boolean} + */ + equals(other) { + if (!(other instanceof GitSha)) {return false;} + return this._value === other._value; + } + + /** + * Returns the short form (first 7 characters) of the SHA + * @returns {string} + */ + toShort() { + return this._value.substring(0, GitSha.SHORT_LENGTH); + } + + /** + * Returns if this is the empty tree SHA + * @returns {boolean} + */ + isEmptyTree() { + return this._value === GitSha.EMPTY_TREE_VALUE; + } + + /** + * Empty tree SHA constant + * @returns {GitSha} + */ + static get EMPTY_TREE() { + return new GitSha(GitSha.EMPTY_TREE_VALUE); + } +} diff --git a/src/domain/value-objects/GitSignature.js b/src/domain/value-objects/GitSignature.js new file mode 100644 index 0000000..028c167 --- /dev/null +++ b/src/domain/value-objects/GitSignature.js @@ -0,0 +1,52 @@ +/** + * @fileoverview GitSignature value object - represents author/committer information + */ + +import ValidationError from '../errors/ValidationError.js'; +import { GitSignatureSchema } from '../schemas/GitSignatureSchema.js'; + +/** + * Represents a Git signature (author or committer) + */ +export default class GitSignature { + /** + * @param {Object} data + * @param {string} data.name - Name of the person + * @param {string} data.email - Email of the person + * @param {number} [data.timestamp] - Unix timestamp (seconds) + */ + constructor(data) { + const result = GitSignatureSchema.safeParse(data); + if (!result.success) { + throw new ValidationError( + `Invalid signature: ${result.error.errors[0].message}`, + 'GitSignature.constructor', + { data, errors: result.error.errors } + ); + } + + this.name = result.data.name; + this.email = result.data.email; + this.timestamp = result.data.timestamp; + } + + /** + * Returns the signature in Git format: "Name timestamp" + * @returns {string} + */ + toString() { + return `${this.name} <${this.email}> ${this.timestamp}`; + } + + /** + * Returns the JSON representation + * @returns {Object} + */ + toJSON() { + return { + name: this.name, + email: this.email, + timestamp: this.timestamp + }; + } +} diff --git a/src/infrastructure/GitStream.js b/src/infrastructure/GitStream.js new file mode 100644 index 0000000..1b619f1 --- /dev/null +++ b/src/infrastructure/GitStream.js @@ -0,0 +1,148 @@ +/** + * @fileoverview Universal wrapper for Node.js and Web Streams + */ + +import { DEFAULT_MAX_BUFFER_SIZE } from '../ports/RunnerOptionsSchema.js'; + +/** + * GitStream provides a unified interface for consuming command output + * across Node.js, Bun, and Deno runtimes. + */ +export default class GitStream { + /** + * @param {ReadableStream|import('node:stream').Readable} stream + * @param {Promise<{code: number, stderr: string}>} [exitPromise] + */ + constructor(stream, exitPromise = Promise.resolve({ code: 0, stderr: '' })) { + this._stream = stream; + this.finished = exitPromise; + this._consumed = false; + } + + /** + * Returns a reader compatible with the Web Streams API. + * Favor native async iteration for Node.js streams to avoid manual listener management. + * @returns {{read: function(): Promise<{done: boolean, value: any}>, releaseLock: function(): void}} + */ + getReader() { + if (typeof this._stream.getReader === 'function') { + return this._stream.getReader(); + } + + // Node.js stream adapter using async iterator + const it = this._stream[Symbol.asyncIterator](); + + return { + read: async () => { + try { + const { done, value } = await it.next(); + return { done, value }; + } catch (err) { + /** + * Handle premature close in Node.js. + * This happens if the underlying process exits or is killed before the stream ends. + */ + if (err.code === 'ERR_STREAM_PREMATURE_CLOSE') { + return { done: true, value: undefined }; + } + throw err; + } + }, + releaseLock: () => {} + }; + } + + /** + * Collects the entire stream into a Uint8Array or string, with a safety limit on bytes. + * Uses an array of chunks to avoid redundant allocations. + * @param {Object} options + * @param {number} [options.maxBytes=DEFAULT_MAX_BUFFER_SIZE] + * @param {boolean} [options.asString=false] - Whether to decode the final buffer to a string. + * @param {string} [options.encoding='utf-8'] - The encoding to use if asString is true. + * @returns {Promise} + * @throws {Error} If maxBytes is exceeded. + */ + async collect({ maxBytes = DEFAULT_MAX_BUFFER_SIZE, asString = false, encoding = 'utf-8' } = {}) { + const chunks = []; + let totalBytes = 0; + + try { + for await (const chunk of this) { + // Optimized: Check for Uint8Array to avoid redundant encoding + const bytes = chunk instanceof Uint8Array ? chunk : new TextEncoder().encode(String(chunk)); + + if (totalBytes + bytes.length > maxBytes) { + throw new Error(`Buffer limit exceeded: ${maxBytes} bytes`); + } + + chunks.push(bytes); + totalBytes += bytes.length; + } + + const result = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + + if (asString) { + return new TextDecoder(encoding).decode(result); + } + + return result; + } finally { + await this.destroy(); + } + } + + /** + * Implements the Async Iterable protocol + */ + async *[Symbol.asyncIterator]() { + if (this._consumed) { + throw new Error('Stream has already been consumed'); + } + this._consumed = true; + + try { + // Favor native async iterator if available (Node 10+, Deno, Bun) + if (typeof this._stream[Symbol.asyncIterator] === 'function') { + yield* this._stream; + return; + } + + // Fallback to reader-based iteration + const reader = this.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + yield value; + } + } finally { + reader.releaseLock(); + } + } finally { + await this.destroy(); + } + } + + /** + * Closes the underlying stream and releases resources. + * @returns {Promise} + */ + async destroy() { + try { + if (typeof this._stream.destroy === 'function') { + this._stream.destroy(); + } else if (typeof this._stream.cancel === 'function') { + await this._stream.cancel(); + } + } catch { + // Ignore errors during destruction + } + } +} diff --git a/src/infrastructure/adapters/bun/BunShellRunner.js b/src/infrastructure/adapters/bun/BunShellRunner.js new file mode 100644 index 0000000..aaf0e73 --- /dev/null +++ b/src/infrastructure/adapters/bun/BunShellRunner.js @@ -0,0 +1,68 @@ +/** + * @fileoverview Bun implementation of the shell command runner (Streaming Only) + */ + +import { RunnerResultSchema } from '../../../ports/RunnerResultSchema.js'; +import EnvironmentPolicy from '../../../domain/services/EnvironmentPolicy.js'; + +/** + * Executes shell commands using Bun.spawn and always returns a stream. + */ +export default class BunShellRunner { + /** + * Executes a command + * @type {import('../../../ports/CommandRunnerPort.js').CommandRunner} + */ + async run({ command, args, cwd, input, timeout, env: envOverrides }) { + // Create a clean environment using Domain Policy + const baseEnv = EnvironmentPolicy.filter(globalThis.process?.env || {}); + const env = envOverrides ? { ...baseEnv, ...EnvironmentPolicy.filter(envOverrides) } : baseEnv; + + const process = Bun.spawn([command, ...args], { + cwd, + env, + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + if (input) { + process.stdin.write(input); + process.stdin.end(); + } else { + process.stdin.end(); + } + + const exitPromise = (async () => { + let timeoutId; + const timeoutPromise = timeout && timeout > 0 + ? new Promise((resolve) => { + timeoutId = setTimeout(() => { + try { process.kill(); } catch { /* ignore */ } + resolve({ code: 1, stderr: 'Command timed out', timedOut: true }); + }, timeout); + }) + : null; + + const completionPromise = (async () => { + const code = await process.exited; + const stderr = await new Response(process.stderr).text(); + if (timeoutId) { + clearTimeout(timeoutId); + } + return { code, stderr, timedOut: false }; + })(); + + if (!timeoutPromise) { + return completionPromise; + } + + return Promise.race([completionPromise, timeoutPromise]); + })(); + + return RunnerResultSchema.parse({ + stdoutStream: process.stdout, + exitPromise + }); + } +} diff --git a/src/infrastructure/adapters/deno/DenoShellRunner.js b/src/infrastructure/adapters/deno/DenoShellRunner.js new file mode 100644 index 0000000..2145e83 --- /dev/null +++ b/src/infrastructure/adapters/deno/DenoShellRunner.js @@ -0,0 +1,81 @@ +/** + * @fileoverview Deno implementation of the shell command runner (Streaming Only) + */ + +import { RunnerResultSchema } from '../../../ports/RunnerResultSchema.js'; +import EnvironmentPolicy from '../../../domain/services/EnvironmentPolicy.js'; + +const ENCODER = new TextEncoder(); +const DECODER = new TextDecoder(); + +/** + * Executes shell commands using Deno.Command and always returns a stream. + */ +export default class DenoShellRunner { + /** + * Executes a command + * @type {import('../../../ports/CommandRunnerPort.js').CommandRunner} + */ + async run({ command, args, cwd, input, timeout, env: envOverrides }) { + // Create a clean environment using Domain Policy + const baseEnv = EnvironmentPolicy.filter(Deno.env.toObject()); + const env = envOverrides ? { ...baseEnv, ...EnvironmentPolicy.filter(envOverrides) } : baseEnv; + + const cmd = new Deno.Command(command, { + args, + cwd, + env, + stdin: 'piped', + stdout: 'piped', + stderr: 'piped', + }); + + const child = cmd.spawn(); + + if (input && child.stdin) { + const writer = child.stdin.getWriter(); + writer.write(typeof input === 'string' ? ENCODER.encode(input) : input); + await writer.close(); + } else if (child.stdin) { + await child.stdin.close(); + } + + const stderrPromise = (async () => { + let stderr = ''; + if (child.stderr) { + for await (const chunk of child.stderr) { + stderr += DECODER.decode(chunk); + } + } + return stderr; + })(); + + const exitPromise = (async () => { + let timeoutId; + const timeoutPromise = new Promise((resolve) => { + if (timeout) { + timeoutId = setTimeout(() => { + try { child.kill("SIGTERM"); } catch { /* ignore */ } + resolve({ code: 1, stderr: 'Command timed out', timedOut: true }); + }, timeout); + } + }); + + const completionPromise = (async () => { + const { code } = await child.status; + const stderr = await stderrPromise; + if (timeoutId) { + clearTimeout(timeoutId); + } + return { code, stderr, timedOut: false }; + })(); + + return Promise.race([completionPromise, timeoutPromise]); + })(); + + return RunnerResultSchema.parse({ + stdoutStream: child.stdout, + exitPromise + }); + } +} \ No newline at end of file diff --git a/src/infrastructure/adapters/node/NodeShellRunner.js b/src/infrastructure/adapters/node/NodeShellRunner.js new file mode 100644 index 0000000..99f4a64 --- /dev/null +++ b/src/infrastructure/adapters/node/NodeShellRunner.js @@ -0,0 +1,65 @@ +/** + * @fileoverview Node.js implementation of the shell command runner (Streaming Only) + */ + +import { spawn } from 'node:child_process'; +import { RunnerResultSchema } from '../../../ports/RunnerResultSchema.js'; +import { DEFAULT_MAX_STDERR_SIZE } from '../../../ports/RunnerOptionsSchema.js'; +import EnvironmentPolicy from '../../../domain/services/EnvironmentPolicy.js'; + +/** + * Executes shell commands using Node.js spawn and always returns a stream. + */ +export default class NodeShellRunner { + /** + * Executes a command + * @type {import('../../../ports/CommandRunnerPort.js').CommandRunner} + */ + async run({ command, args, cwd, input, timeout, env: envOverrides }) { + // Create a clean environment using Domain Policy + const baseEnv = EnvironmentPolicy.filter(globalThis.process?.env || {}); + const env = envOverrides ? { ...baseEnv, ...EnvironmentPolicy.filter(envOverrides) } : baseEnv; + + const child = spawn(command, args, { cwd, env }); + + if (child.stdin) { + if (input) { + child.stdin.end(input); + } else { + child.stdin.end(); + } + } + + let stderr = ''; + child.stderr?.on('data', (chunk) => { + if (stderr.length < DEFAULT_MAX_STDERR_SIZE) { + stderr += chunk.toString(); + } + }); + + const exitPromise = new Promise((resolve) => { + let timeoutId; + if (typeof timeout === 'number' && timeout > 0) { + timeoutId = setTimeout(() => { + child.kill(); + resolve({ code: 1, stderr, timedOut: true }); + }, timeout); + } + + child.on('exit', (code) => { + if (timeoutId) {clearTimeout(timeoutId);} + resolve({ code: code ?? 1, stderr, timedOut: false }); + }); + + child.on('error', (err) => { + if (timeoutId) {clearTimeout(timeoutId);} + resolve({ code: 1, stderr: `${stderr}\n${err.message}`, timedOut: false, error: err }); + }); + }); + + return RunnerResultSchema.parse({ + stdoutStream: child.stdout, + exitPromise + }); + } +} \ No newline at end of file diff --git a/src/infrastructure/factories/ShellRunnerFactory.js b/src/infrastructure/factories/ShellRunnerFactory.js new file mode 100644 index 0000000..f7a10e8 --- /dev/null +++ b/src/infrastructure/factories/ShellRunnerFactory.js @@ -0,0 +1,108 @@ +/** + * @fileoverview Factory for creating shell runners based on the environment + */ + +import NodeShellRunner from '../adapters/node/NodeShellRunner.js'; +import BunShellRunner from '../adapters/bun/BunShellRunner.js'; +import DenoShellRunner from '../adapters/deno/DenoShellRunner.js'; + +/** + * Factory for shell runners + */ +export default class ShellRunnerFactory { + static ENV_BUN = 'bun'; + static ENV_DENO = 'deno'; + static ENV_NODE = 'node'; + + /** @private */ + static _registry = new Map(); + + /** + * Registers a custom runner class. + * @param {string} name + * @param {Function} RunnerClass + */ + static register(name, RunnerClass) { + this._registry.set(name, RunnerClass); + } + + /** + * Creates a shell runner for the current environment + * @param {Object} [options] + * @param {string} [options.env] - Override environment detection. + * @returns {import('../../ports/CommandRunnerPort.js').CommandRunner} A functional shell runner + */ + static create(options = {}) { + const env = options.env || this._detectEnvironment(); + + // Check registry first + if (this._registry.has(env)) { + const RunnerClass = this._registry.get(env); + const runner = new RunnerClass(); + return runner.run.bind(runner); + } + + const runners = { + [this.ENV_BUN]: BunShellRunner, + [this.ENV_DENO]: DenoShellRunner, + [this.ENV_NODE]: NodeShellRunner + }; + + const RunnerClass = runners[env]; + if (!RunnerClass) { + throw new Error(`Unsupported environment: ${env}`); + } + + const runner = new RunnerClass(); + return runner.run.bind(runner); + } + + /** + * Resolves and validates a working directory using runtime-specific APIs. + * @param {string} cwd + * @returns {Promise} The resolved absolute path. + */ + static async validateCwd(cwd) { + const env = this._detectEnvironment(); + + if (env === this.ENV_NODE || env === this.ENV_BUN) { + const { resolve } = await import('node:path'); + const { existsSync, statSync } = await import('node:fs'); + const resolved = resolve(cwd); + if (!existsSync(resolved) || !statSync(resolved).isDirectory()) { + throw new Error(`Invalid working directory: ${cwd}`); + } + return resolved; + } + + if (env === this.ENV_DENO) { + try { + const resolved = await Deno.realPath(cwd); + const info = await Deno.stat(resolved); + if (!info.isDirectory) { + throw new Error('Not a directory'); + } + return resolved; + } catch { + throw new Error(`Invalid working directory: ${cwd}`); + } + } + + return cwd; + } + + /** + * Detects the current execution environment + * @private + * @returns {string} + */ + static _detectEnvironment() { + if (typeof globalThis.Bun !== 'undefined') { + return this.ENV_BUN; + } + if (typeof globalThis.Deno !== 'undefined') { + return this.ENV_DENO; + } + return this.ENV_NODE; + } +} \ No newline at end of file diff --git a/src/ports/CommandRunnerPort.js b/src/ports/CommandRunnerPort.js new file mode 100644 index 0000000..87c1149 --- /dev/null +++ b/src/ports/CommandRunnerPort.js @@ -0,0 +1,18 @@ +/** + * @fileoverview CommandRunner port definition + */ + +import { DEFAULT_COMMAND_TIMEOUT, DEFAULT_MAX_BUFFER_SIZE, DEFAULT_MAX_STDERR_SIZE } from './RunnerOptionsSchema.js'; + +export { DEFAULT_COMMAND_TIMEOUT, DEFAULT_MAX_BUFFER_SIZE, DEFAULT_MAX_STDERR_SIZE }; + +/** + * @typedef {import('./RunnerOptionsSchema.js').RunnerOptions} RunnerOptions + * @typedef {import('./RunnerResultSchema.js').RunnerResult} RunnerResult + */ + +/** + * @callback CommandRunner + * @param {RunnerOptions} options + * @returns {Promise} + */ diff --git a/src/ports/GitPersistencePort.js b/src/ports/GitPersistencePort.js new file mode 100644 index 0000000..d1befb7 --- /dev/null +++ b/src/ports/GitPersistencePort.js @@ -0,0 +1,10 @@ +/** + * @fileoverview GitPersistencePort - Functional port for Git object persistence + */ + +/** + * @typedef {Object} GitPersistencePort + * @property {function(import('../domain/entities/GitBlob.js').default): Promise} writeBlob + * @property {function(import('../domain/entities/GitTree.js').default): Promise} writeTree + * @property {function(import('../domain/entities/GitCommit.js').default): Promise} writeCommit + */ diff --git a/src/ports/RunnerOptionsSchema.js b/src/ports/RunnerOptionsSchema.js new file mode 100644 index 0000000..df6c25c --- /dev/null +++ b/src/ports/RunnerOptionsSchema.js @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +/** + * Default timeout for shell commands in milliseconds. + */ +export const DEFAULT_COMMAND_TIMEOUT = 120000; + +/** + * Default maximum size for command output buffer in bytes (10MB). + */ +export const DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024; + +/** + * Default maximum size for stderr buffer in bytes (1MB). + */ +export const DEFAULT_MAX_STDERR_SIZE = 1024 * 1024; + +/** + * Zod schema for CommandRunner options. + */ +export const RunnerOptionsSchema = z.object({ + command: z.string(), + args: z.array(z.string()), + cwd: z.string().optional(), + input: z.union([z.string(), z.instanceof(Uint8Array)]).optional(), + env: z.record(z.string()).optional(), + timeout: z.number().optional().default(DEFAULT_COMMAND_TIMEOUT), +}); + +/** + * @typedef {z.infer} RunnerOptions + */ diff --git a/src/ports/RunnerResultSchema.js b/src/ports/RunnerResultSchema.js new file mode 100644 index 0000000..00a60f1 --- /dev/null +++ b/src/ports/RunnerResultSchema.js @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +/** + * Zod schema for the result returned by a CommandRunner. + */ +export const RunnerResultSchema = z.object({ + stdoutStream: z.any(), // ReadableStream (Web) or Readable (Node) + exitPromise: z.instanceof(Promise), // Resolves to {code, stderr} when process ends +}); + +/** + * @typedef {z.infer} RunnerResult + */ diff --git a/test.js b/test.js index 4832213..17f66bb 100644 --- a/test.js +++ b/test.js @@ -1,28 +1,35 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +/** + * @fileoverview Integration tests for GitPlumbing + */ + import { mkdtempSync, rmSync } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import GitPlumbing from './index.js'; +import GitRepositoryService from './src/domain/services/GitRepositoryService.js'; describe('GitPlumbing', () => { let tempDir; let plumbing; + let repo; - beforeEach(() => { + beforeEach(async () => { tempDir = mkdtempSync(path.join(os.tmpdir(), 'git-plumbing-test-')); plumbing = new GitPlumbing({ cwd: tempDir }); + repo = new GitRepositoryService({ plumbing }); // Initialize a repo for testing - plumbing.execute({ args: ['init'] }); - plumbing.execute({ args: ['config', 'user.name', 'Tester'] }); - plumbing.execute({ args: ['config', 'user.email', 'test@example.com'] }); + await plumbing.execute({ args: ['init'] }); + await plumbing.verifyInstallation(); + await plumbing.execute({ args: ['config', 'user.name', 'Tester'] }); + await plumbing.execute({ args: ['config', 'user.email', 'test@example.com'] }); }); afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); }); - it('executes basic git commands', () => { - const out = plumbing.execute({ args: ['rev-parse', '--is-inside-work-tree'] }); + it('executes basic git commands', async () => { + const out = await plumbing.execute({ args: ['rev-parse', '--is-inside-work-tree'] }); expect(out).toBe('true'); }); @@ -30,27 +37,25 @@ describe('GitPlumbing', () => { expect(plumbing.emptyTree).toBe('4b825dc642cb6eb9a060e54bf8d69288fbee4904'); }); - it('updates and parses refs', () => { - const commitSha = plumbing.execute({ + it('updates and parses refs', async () => { + const commitSha = await plumbing.execute({ args: ['commit-tree', plumbing.emptyTree, '-m', 'test'] }); - plumbing.updateRef({ ref: 'refs/heads/test', newSha: commitSha }); - const resolved = plumbing.revParse({ revision: 'refs/heads/test' }); + await repo.updateRef({ ref: 'refs/heads/test', newSha: commitSha }); + const resolved = await repo.revParse({ revision: 'refs/heads/test' }); expect(resolved).toBe(commitSha); }); - it('handles errors with telemetry', () => { - try { - plumbing.execute({ args: ['rev-parse', '--non-existent-flag'] }); - } catch (err) { - expect(err.message).toContain('Stderr:'); - } + it('handles errors with telemetry', async () => { + await expect( + plumbing.execute({ args: ['rev-parse', '--non-existent-flag'] }) + ).rejects.toThrow('Git command failed'); }); - it('executes with status for non-zero exit codes', () => { - const result = plumbing.executeWithStatus({ args: ['rev-parse', '--non-existent-flag'] }); + it('executes with status for non-zero exit codes', async () => { + const result = await plumbing.executeWithStatus({ args: ['rev-parse', '--non-existent-flag'] }); expect(result.status).not.toBe(0); }); }); diff --git a/test/GitBlob.test.js b/test/GitBlob.test.js new file mode 100644 index 0000000..84b057e --- /dev/null +++ b/test/GitBlob.test.js @@ -0,0 +1,87 @@ +import GitBlob from '../src/domain/entities/GitBlob.js'; +import GitSha from '../src/domain/value-objects/GitSha.js'; +import ValidationError from '../src/domain/errors/ValidationError.js'; + +const BLOB_CONTENT = 'Hello, world!'; +const EMPTY_CONTENT = ''; +const HELLO_BYTES = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" + +describe('GitBlob', () => { + describe('constructor', () => { + it('creates a GitBlob with null SHA and content', () => { + const blob = new GitBlob(null, BLOB_CONTENT); + expect(blob.sha).toBeNull(); + expect(blob.content).toBe(BLOB_CONTENT); + }); + + it('creates a GitBlob with SHA and content', () => { + const sha = GitSha.EMPTY_TREE; + const blob = new GitBlob(sha, BLOB_CONTENT); + expect(blob.sha).toBe(sha); + expect(blob.content).toBe(BLOB_CONTENT); + }); + + it('throws error when SHA is not a GitSha instance', () => { + expect(() => new GitBlob('invalid-sha', BLOB_CONTENT)).toThrow(ValidationError); + }); + + it('accepts binary content', () => { + const blob = new GitBlob(null, HELLO_BYTES); + // Use toEqual for structural equality since we now defensively copy + expect(blob.content).toEqual(HELLO_BYTES); + }); + }); + + describe('static fromContent', () => { + it('creates GitBlob from string content', () => { + const blob = GitBlob.fromContent(BLOB_CONTENT); + expect(blob).toBeInstanceOf(GitBlob); + expect(blob.sha).toBeNull(); + expect(blob.content).toBe(BLOB_CONTENT); + }); + + it('creates GitBlob from binary content', () => { + const blob = GitBlob.fromContent(HELLO_BYTES); + expect(blob).toBeInstanceOf(GitBlob); + expect(blob.sha).toBeNull(); + expect(blob.content).toEqual(HELLO_BYTES); + }); + }); + + describe('isWritten', () => { + it('returns false for unwritten blob', () => { + const blob = new GitBlob(null, BLOB_CONTENT); + expect(blob.isWritten()).toBe(false); + }); + + it('returns true for written blob', () => { + const sha = GitSha.EMPTY_TREE; + const blob = new GitBlob(sha, BLOB_CONTENT); + expect(blob.isWritten()).toBe(true); + }); + }); + + describe('size', () => { + it('returns string content size in bytes', () => { + const blob = new GitBlob(null, BLOB_CONTENT); + expect(blob.size()).toBe(BLOB_CONTENT.length); + }); + + it('returns binary content size', () => { + const blob = new GitBlob(null, HELLO_BYTES); + expect(blob.size()).toBe(HELLO_BYTES.length); + }); + + it('returns 0 for empty content', () => { + const blob = new GitBlob(null, EMPTY_CONTENT); + expect(blob.size()).toBe(0); + }); + }); + + describe('type', () => { + it('returns blob type', () => { + const blob = new GitBlob(null, BLOB_CONTENT); + expect(blob.type().isBlob()).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/test/GitCommitFlow.test.js b/test/GitCommitFlow.test.js new file mode 100644 index 0000000..1ed08df --- /dev/null +++ b/test/GitCommitFlow.test.js @@ -0,0 +1,59 @@ +import GitPlumbing from '../index.js'; +import GitSignature from '../src/domain/value-objects/GitSignature.js'; +import GitSha from '../src/domain/value-objects/GitSha.js'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; + +describe('Git Commit Flow', () => { + let git; + let repoPath; + + beforeAll(async () => { + repoPath = path.join(os.tmpdir(), `git-plumbing-test-${Math.random().toString(36).substring(7)}`); + fs.mkdirSync(repoPath, { recursive: true }); + + git = GitPlumbing.createDefault({ cwd: repoPath }); + + // Initialize repository + await git.execute({ args: ['init'] }); + }); + + afterAll(() => { + fs.rmSync(repoPath, { recursive: true, force: true }); + }); + + it('orchestrates a full commit sequence', async () => { + const signature = new GitSignature({ + name: 'James Ross', + email: 'james@flyingrobots.dev', + timestamp: Math.floor(Date.now() / 1000) + }); + + const commitSha = await git.commit({ + branch: 'refs/heads/main', + message: 'Orchestrated commit', + author: signature, + committer: signature, + parents: [], + files: [ + { path: 'hello.txt', content: 'Hello from flow!' }, + { path: 'test.bin', content: new Uint8Array([1, 2, 3, 4, 5]) } + ] + }); + + expect(commitSha).toBeInstanceOf(GitSha); + + // Verify the commit exists + const verifiedMessage = await git.execute({ + args: ['cat-file', '-p', commitSha.toString()] + }); + expect(verifiedMessage).toContain('Orchestrated commit'); + + // Verify reference was updated + const headSha = await git.execute({ + args: ['rev-parse', 'refs/heads/main'] + }); + expect(headSha.trim()).toBe(commitSha.toString()); + }); +}); diff --git a/test/GitRef.test.js b/test/GitRef.test.js new file mode 100644 index 0000000..0b24633 --- /dev/null +++ b/test/GitRef.test.js @@ -0,0 +1,233 @@ +import GitRef from '../src/domain/value-objects/GitRef.js'; +import ValidationError from '../src/domain/errors/ValidationError.js'; + +const VALID_REF_1 = 'refs/heads/main'; +const VALID_REF_2 = 'refs/tags/v1.0.0'; +const VALID_REF_3 = 'refs/remotes/origin/main'; +const INVALID_REF_DOT_START = '.refs/heads/main'; +const INVALID_REF_DOUBLE_DOT = 'refs/heads/../main'; +const INVALID_REF_DOT_END = 'refs/heads/main.'; +const INVALID_REF_SLASH_DOT = 'refs/heads/.main'; +const INVALID_REF_AT_ALONE = '@'; +const INVALID_REF_REFLOG = 'refs/heads/foo@{bar'; +const INVALID_REF_BACKSLASH = 'refs/heads\\main'; +const INVALID_REF_CONTROL_CHARS = 'refs/heads/main\x00'; +const INVALID_REF_SPACE = 'refs/heads/main branch'; +const INVALID_REF_CONSECUTIVE_SLASHES = 'refs//heads/main'; + +describe('GitRef', () => { + describe('constructor', () => { + it('creates a valid GitRef from a valid reference string', () => { + const ref = new GitRef(VALID_REF_1); + expect(ref.toString()).toBe(VALID_REF_1); + }); + + it('throws error for invalid reference string', () => { + expect(() => new GitRef(INVALID_REF_DOT_START)).toThrow(ValidationError); + expect(() => new GitRef(INVALID_REF_DOT_START)).toThrow('Invalid Git reference: .refs/heads/main'); + }); + + it('throws error for reference starting with dot', () => { + expect(() => new GitRef(INVALID_REF_DOT_START)).toThrow(); + }); + + it('throws error for reference containing double dots', () => { + expect(() => new GitRef(INVALID_REF_DOUBLE_DOT)).toThrow(); + }); + + it('throws error for reference ending with dot', () => { + expect(() => new GitRef(INVALID_REF_DOT_END)).toThrow(); + }); + + it('throws error for reference containing slash-dot', () => { + expect(() => new GitRef(INVALID_REF_SLASH_DOT)).toThrow(); + }); + + it("throws error for reference being '@' alone", () => { + expect(() => new GitRef(INVALID_REF_AT_ALONE)).toThrow(); + }); + + it("throws error for reference containing '@'{", () => { + expect(() => new GitRef(INVALID_REF_REFLOG)).toThrow(); + }); + + it('throws error for reference containing backslash', () => { + expect(() => new GitRef(INVALID_REF_BACKSLASH)).toThrow(); + }); + + it('throws error for reference containing control characters', () => { + expect(() => new GitRef(INVALID_REF_CONTROL_CHARS)).toThrow(); + }); + + it('throws error for reference with space', () => { + expect(() => new GitRef(INVALID_REF_SPACE)).toThrow(); + }); + + it('throws error for reference with consecutive slashes', () => { + expect(() => new GitRef(INVALID_REF_CONSECUTIVE_SLASHES)).toThrow(); + }); + + it("allows '@' if it doesn't form reflog sequence", () => { + expect(() => new GitRef('refs/heads/user@feature')).not.toThrow(); + }); + }); + + describe('static isValid', () => { + it('returns true for valid branch reference', () => { + expect(GitRef.isValid(VALID_REF_1)).toBe(true); + }); + + it('returns true for valid tag reference', () => { + expect(GitRef.isValid(VALID_REF_2)).toBe(true); + }); + + it('returns true for valid remote reference', () => { + expect(GitRef.isValid(VALID_REF_3)).toBe(true); + }); + + it('returns false for invalid reference', () => { + expect(GitRef.isValid(INVALID_REF_DOT_START)).toBe(false); + }); + + it('returns false for non-string input', () => { + expect(GitRef.isValid(123)).toBe(false); + expect(GitRef.isValid(null)).toBe(false); + expect(GitRef.isValid(undefined)).toBe(false); + }); + }); + + describe('static fromString', () => { + it('creates GitRef from valid string', () => { + const ref = GitRef.fromString(VALID_REF_1); + expect(ref).toBeInstanceOf(GitRef); + expect(ref.toString()).toBe(VALID_REF_1); + }); + + it('throws error for invalid string', () => { + expect(() => GitRef.fromString(INVALID_REF_DOT_START)).toThrow('Invalid Git reference: .refs/heads/main'); + }); + }); + + describe('static fromStringOrNull', () => { + it('creates GitRef from valid string', () => { + const ref = GitRef.fromStringOrNull(VALID_REF_1); + expect(ref).toBeInstanceOf(GitRef); + expect(ref.toString()).toBe(VALID_REF_1); + }); + + it('returns null for invalid string', () => { + const ref = GitRef.fromStringOrNull(INVALID_REF_DOT_START); + expect(ref).toBeNull(); + }); + }); + + describe('equals', () => { + it('returns true for equal refs', () => { + const ref1 = new GitRef(VALID_REF_1); + const ref2 = new GitRef(VALID_REF_1); + expect(ref1.equals(ref2)).toBe(true); + }); + + it('returns false for different refs', () => { + const ref1 = new GitRef(VALID_REF_1); + const ref2 = new GitRef(VALID_REF_2); + expect(ref1.equals(ref2)).toBe(false); + }); + + it('returns false when comparing with non-GitRef', () => { + const ref = new GitRef(VALID_REF_1); + expect(ref.equals(VALID_REF_1)).toBe(false); + expect(ref.equals(null)).toBe(false); + expect(ref.equals({})).toBe(false); + }); + }); + + describe('isBranch', () => { + it('returns true for branch reference', () => { + const ref = new GitRef(VALID_REF_1); + expect(ref.isBranch()).toBe(true); + }); + + it('returns false for non-branch reference', () => { + const ref = new GitRef(VALID_REF_2); + expect(ref.isBranch()).toBe(false); + }); + }); + + describe('isTag', () => { + it('returns true for tag reference', () => { + const ref = new GitRef(VALID_REF_2); + expect(ref.isTag()).toBe(true); + }); + + it('returns false for non-tag reference', () => { + const ref = new GitRef(VALID_REF_1); + expect(ref.isTag()).toBe(false); + }); + }); + + describe('isRemote', () => { + it('returns true for remote reference', () => { + const ref = new GitRef(VALID_REF_3); + expect(ref.isRemote()).toBe(true); + }); + + it('returns false for non-remote reference', () => { + const ref = new GitRef(VALID_REF_1); + expect(ref.isRemote()).toBe(false); + }); + }); + + describe('shortName', () => { + it('returns short name for branch reference', () => { + const ref = new GitRef(VALID_REF_1); + expect(ref.shortName()).toBe('main'); + }); + + it('returns short name for tag reference', () => { + const ref = new GitRef(VALID_REF_2); + expect(ref.shortName()).toBe('v1.0.0'); + }); + + it('returns short name for remote reference', () => { + const ref = new GitRef(VALID_REF_3); + expect(ref.shortName()).toBe('origin/main'); + }); + + it('returns full reference for other refs', () => { + const ref = new GitRef('refs/other/unknown'); + expect(ref.shortName()).toBe('refs/other/unknown'); + }); + }); + + describe('static branch', () => { + it('creates branch reference', () => { + const ref = GitRef.branch('feature/new'); + expect(ref.toString()).toBe('refs/heads/feature/new'); + expect(ref.isBranch()).toBe(true); + }); + }); + + describe('static tag', () => { + it('creates tag reference', () => { + const ref = GitRef.tag('v2.0.0'); + expect(ref.toString()).toBe('refs/tags/v2.0.0'); + expect(ref.isTag()).toBe(true); + }); + }); + + describe('static remote', () => { + it('creates remote reference', () => { + const ref = GitRef.remote('origin', 'develop'); + expect(ref.toString()).toBe('refs/remotes/origin/develop'); + expect(ref.isRemote()).toBe(true); + }); + }); + + describe('JSON serialization', () => { + it('serializes to string representation', () => { + const ref = new GitRef(VALID_REF_1); + expect(JSON.stringify(ref)).toBe(`"${VALID_REF_1}"`); + }); + }); +}); \ No newline at end of file diff --git a/test/GitSha.test.js b/test/GitSha.test.js new file mode 100644 index 0000000..62420fb --- /dev/null +++ b/test/GitSha.test.js @@ -0,0 +1,92 @@ +import GitSha from '../src/domain/value-objects/GitSha.js'; +import ValidationError from '../src/domain/errors/ValidationError.js'; + +const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; +const VALID_SHA_1 = 'a1b2c3d4e5f67890123456789012345678901234'; +const VALID_SHA_2 = 'f1e2d3c4b5a697887766554433221100ffeeddcc'; +const INVALID_SHA_ALPHABETIC = 'gggggggggggggggggggggggggggggggggggggggg'; +const INVALID_SHA_TOO_SHORT = '4b825dc642cb6eb9a060e54bf8d69288fbee490'; +const INVALID_SHA_TOO_LONG = '4b825dc642cb6eb9a060e54bf8d69288fbee49044'; +const INVALID_SHA_MIXED_CASE = '4B825DC642CB6EB9A060E54BF8D69288FBEE4904'; + +describe('GitSha', () => { + describe('static from', () => { + it('creates a valid GitSha from a valid SHA string', () => { + const sha = GitSha.from(EMPTY_TREE_SHA); + expect(sha.toString()).toBe(EMPTY_TREE_SHA); + }); + + it('throws ValidationError for invalid SHA string', () => { + let error; + try { + GitSha.from(INVALID_SHA_ALPHABETIC); + } catch (err) { + error = err; + } + expect(error).toBeInstanceOf(ValidationError); + expect(error.message).toContain('Invalid SHA-1 hash'); + expect(error.details.helpUrl).toBe('https://git-scm.com/book/en/v2/Git-Internals-Git-Objects'); + }); + + it('throws error for SHA with wrong length', () => { + expect(() => GitSha.from(INVALID_SHA_TOO_SHORT)).toThrow(ValidationError); + expect(() => GitSha.from(INVALID_SHA_TOO_LONG)).toThrow(ValidationError); + }); + + it('throws error for SHA with invalid characters', () => { + expect(() => GitSha.from(INVALID_SHA_ALPHABETIC)).toThrow(ValidationError); + }); + + it('converts SHA to lowercase', () => { + const sha = GitSha.from(INVALID_SHA_MIXED_CASE); + expect(sha.toString()).toBe(EMPTY_TREE_SHA); + }); + }); + + describe('equals', () => { + it('returns true for equal SHAs', () => { + const sha1 = GitSha.from(EMPTY_TREE_SHA); + const sha2 = GitSha.from(EMPTY_TREE_SHA); + expect(sha1.equals(sha2)).toBe(true); + }); + + it('returns false for different SHAs', () => { + const sha1 = GitSha.from(EMPTY_TREE_SHA); + const sha2 = GitSha.from(VALID_SHA_1); + expect(sha1.equals(sha2)).toBe(false); + }); + + it('returns false when comparing with non-GitSha', () => { + const sha = GitSha.from(EMPTY_TREE_SHA); + expect(sha.equals(EMPTY_TREE_SHA)).toBe(false); + expect(sha.equals(null)).toBe(false); + expect(sha.equals({})).toBe(false); + }); + }); + + describe('toShort', () => { + it('returns first 7 characters of SHA', () => { + const sha = GitSha.from(VALID_SHA_1); + expect(sha.toShort()).toBe('a1b2c3d'); + }); + }); + + describe('isEmptyTree', () => { + it('returns true for empty tree SHA', () => { + const sha = GitSha.EMPTY_TREE; + expect(sha.isEmptyTree()).toBe(true); + }); + + it('returns false for non-empty tree SHA', () => { + const sha = GitSha.from(VALID_SHA_1); + expect(sha.isEmptyTree()).toBe(false); + }); + }); + + describe('JSON serialization', () => { + it('serializes to string representation', () => { + const sha = GitSha.from(VALID_SHA_2); + expect(JSON.stringify(sha)).toBe(`"${VALID_SHA_2}"`); + }); + }); +}); \ No newline at end of file diff --git a/test/ShellRunner.test.js b/test/ShellRunner.test.js new file mode 100644 index 0000000..c2c3762 --- /dev/null +++ b/test/ShellRunner.test.js @@ -0,0 +1,52 @@ +import ShellRunner from '../ShellRunner.js'; + +describe('ShellRunner', () => { + it('executes a simple command (git --version)', async () => { + const result = await ShellRunner.run({ + command: 'git', + args: ['--version'] + }); + + expect(result.stdoutStream).toBeDefined(); + + // Consume stream to avoid hanging and leaks + for await (const _ of result.stdoutStream) { + // noop + } + + const { code } = await result.exitPromise; + expect(code).toBe(0); + }); + + it('captures stderr', async () => { + const result = await ShellRunner.run({ + command: 'git', + args: ['hash-object', '--invalid-flag'] + }); + + // Must consume stdout even if empty to avoid leaks in some runtimes + for await (const _ of result.stdoutStream) { /* noop */ } + + const { code, stderr } = await result.exitPromise; + expect(code).not.toBe(0); + expect(stderr).toContain('unknown option'); + }); + + it('handles stdin', async () => { + const result = await ShellRunner.run({ + command: 'git', + args: ['hash-object', '--stdin'], + input: 'hello world' + }); + + let stdout = ''; + const decoder = new TextDecoder(); + for await (const chunk of result.stdoutStream) { + stdout += typeof chunk === 'string' ? chunk : decoder.decode(chunk); + } + + const { code } = await result.exitPromise; + expect(code).toBe(0); + expect(stdout.trim()).toBe('95d09f2b10159347eece71399a7e2e907ea3df4f'); + }); +}); diff --git a/test/StreamCompletion.test.js b/test/StreamCompletion.test.js new file mode 100644 index 0000000..5860f86 --- /dev/null +++ b/test/StreamCompletion.test.js @@ -0,0 +1,34 @@ + +import GitPlumbing from '../index.js'; +import ShellRunner from '../ShellRunner.js'; + +describe('Stream Completion', () => { + const git = new GitPlumbing({ + runner: ShellRunner.run, + cwd: process.cwd() + }); + + it('provides a finished promise that resolves on success', async () => { + const stream = await git.executeStream({ args: ['--version'] }); + + // Consume stream + const reader = stream.getReader(); + while (!(await reader.read()).done) { /* noop */ } + + const result = await stream.finished; + expect(result.code).toBe(0); + }); + + it('provides a finished promise that captures errors', async () => { + const stream = await git.executeStream({ + args: ['hash-object', '--non-existent-flag'] + }); + + const reader = stream.getReader(); + while (!(await reader.read()).done) { /* noop */ } + + const result = await stream.finished; + expect(result.code).not.toBe(0); + expect(result.stderr).toContain('unknown option'); + }); +}); diff --git a/test/Streaming.test.js b/test/Streaming.test.js new file mode 100644 index 0000000..1a8fd67 --- /dev/null +++ b/test/Streaming.test.js @@ -0,0 +1,46 @@ +import GitPlumbing from '../index.js'; +import ShellRunner from '../ShellRunner.js'; + +describe('Streaming', () => { + const git = new GitPlumbing({ + runner: ShellRunner.run, + cwd: process.cwd() + }); + + it('executes a command and returns a readable stream', async () => { + const gitStream = await git.executeStream({ args: ['--version'] }); + + expect(gitStream).toBeDefined(); + + let output = ''; + const decoder = new TextDecoder(); + + for await (const chunk of gitStream) { + output += typeof chunk === 'string' ? chunk : decoder.decode(chunk); + } + + expect(output).toContain('git'); + }); + + it('handles input in streaming mode', async () => { + // We'll use 'hash-object' via executeStream to verify stdin/stdout piping + const input = 'hello world content'; + + const gitStream = await git.executeStream({ + args: ['hash-object', '--stdin'], + input + }); + + expect(gitStream).toBeDefined(); + + let output = ''; + const decoder = new TextDecoder(); + for await (const chunk of gitStream) { + output += typeof chunk === 'string' ? chunk : decoder.decode(chunk); + } + + // Expected SHA for "hello world content\n" is usually different from raw "hello world content" + // but we just check if we got a valid-looking SHA + expect(output.trim()).toMatch(/^[a-f0-9]{40}$/); + }); +}); \ No newline at end of file diff --git a/test/deno_entry.js b/test/deno_entry.js new file mode 100644 index 0000000..a2b8002 --- /dev/null +++ b/test/deno_entry.js @@ -0,0 +1,24 @@ +import "./deno_shim.js"; + +// Import all tests to run them in one Deno process with the shim +import "./GitBlob.test.js"; +import "./GitCommitFlow.test.js"; +import "./GitRef.test.js"; +import "./GitSha.test.js"; +import "./ShellRunner.test.js"; +import "./StreamCompletion.test.js"; +import "./Streaming.test.js"; +import "./domain/entities/GitCommit.test.js"; +import "./domain/entities/GitCommitBuilder.test.js"; +import "./domain/entities/GitTree.test.js"; +import "./domain/entities/GitTreeBuilder.test.js"; +import "./domain/entities/GitTreeEntry.test.js"; +import "./domain/errors/Errors.test.js"; +import "./domain/services/ByteMeasurer.test.js"; +import "./domain/services/CommandSanitizer.test.js"; +import "./domain/services/EnvironmentPolicy.test.js"; +import "./domain/services/GitCommandBuilder.test.js"; +import "./domain/services/GitErrorClassifier.test.js"; +import "./domain/services/GitPersistenceService.test.js"; +import "./domain/value-objects/GitFileMode.test.js"; +import "./domain/value-objects/GitObjectType.test.js"; \ No newline at end of file diff --git a/test/deno_shim.js b/test/deno_shim.js new file mode 100644 index 0000000..a8d6ea9 --- /dev/null +++ b/test/deno_shim.js @@ -0,0 +1,4 @@ +import { describe, it, beforeEach, afterEach, beforeAll, afterAll } from "https://deno.land/std@0.224.0/testing/bdd.ts"; +import { expect } from "https://deno.land/std@0.224.0/expect/mod.ts"; + +Object.assign(globalThis, { describe, it, beforeEach, afterEach, beforeAll, afterAll, expect }); \ No newline at end of file diff --git a/test/domain/entities/GitCommit.test.js b/test/domain/entities/GitCommit.test.js new file mode 100644 index 0000000..9314d75 --- /dev/null +++ b/test/domain/entities/GitCommit.test.js @@ -0,0 +1,57 @@ +import GitCommit from '../../../src/domain/entities/GitCommit.js'; +import GitSha from '../../../src/domain/value-objects/GitSha.js'; +import GitSignature from '../../../src/domain/value-objects/GitSignature.js'; +import ValidationError from '../../../src/domain/errors/ValidationError.js'; + +describe('GitCommit', () => { + const treeSha = GitSha.EMPTY_TREE; + const author = new GitSignature({ name: 'Author', email: 'author@example.com', timestamp: 1234567890 }); + const committer = new GitSignature({ name: 'Committer', email: 'committer@example.com', timestamp: 1234567890 }); + const message = 'Initial commit'; + + describe('constructor', () => { + it('creates a root commit', () => { + const commit = new GitCommit({ sha: null, treeSha, parents: [], author, committer, message }); + expect(commit.sha).toBeNull(); + expect(commit.treeSha.equals(treeSha)).toBe(true); + expect(commit.parents).toHaveLength(0); + expect(commit.isRoot()).toBe(true); + }); + + it('creates a commit with parents', () => { + const parent = GitSha.from('1234567890abcdef1234567890abcdef12345678'); + const commit = new GitCommit({ sha: null, treeSha, parents: [parent], author, committer, message }); + expect(commit.parents).toHaveLength(1); + expect(commit.parents[0].equals(parent)).toBe(true); + expect(commit.isRoot()).toBe(false); + expect(commit.isMerge()).toBe(false); + }); + + it('throws if treeSha is not a GitSha instance', () => { + expect(() => new GitCommit({ sha: null, treeSha: 'invalid', parents: [], author, committer, message })).toThrow(ValidationError); + }); + }); + + describe('static fromData', () => { + it('creates a commit from raw data', () => { + const data = { + sha: null, + treeSha: treeSha.toString(), + parents: [], + author: { name: 'A', email: 'a@example.com', timestamp: 1 }, + committer: { name: 'C', email: 'c@example.com', timestamp: 2 }, + message: 'msg' + }; + const commit = GitCommit.fromData(data); + expect(commit.message).toBe('msg'); + expect(commit.author).toBeInstanceOf(GitSignature); + }); + }); + + describe('type', () => { + it('returns commit type', () => { + const commit = new GitCommit({ sha: null, treeSha, parents: [], author, committer, message }); + expect(commit.type().isCommit()).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/test/domain/entities/GitCommitBuilder.test.js b/test/domain/entities/GitCommitBuilder.test.js new file mode 100644 index 0000000..3e44e74 --- /dev/null +++ b/test/domain/entities/GitCommitBuilder.test.js @@ -0,0 +1,45 @@ + +import GitCommitBuilder from '../../../src/domain/entities/GitCommitBuilder.js'; +import GitCommit from '../../../src/domain/entities/GitCommit.js'; +import GitSha from '../../../src/domain/value-objects/GitSha.js'; +import GitTree from '../../../src/domain/entities/GitTree.js'; +import GitSignature from '../../../src/domain/value-objects/GitSignature.js'; + +describe('GitCommitBuilder', () => { + const tree = GitTree.empty(); + const author = new GitSignature({ name: 'Author', email: 'author@example.com' }); + const committer = new GitSignature({ name: 'Committer', email: 'committer@example.com' }); + const message = 'Test message'; + + it('builds a valid commit', () => { + const builder = new GitCommitBuilder(); + const commit = builder + .tree(tree) + .author(author) + .committer(committer) + .message(message) + .build(); + + expect(commit).toBeInstanceOf(GitCommit); + expect(commit.message).toBe(message); + expect(commit.treeSha.equals(tree.sha)).toBe(true); + }); + + it('handles parents', () => { + const parent1 = '1234567890abcdef1234567890abcdef12345678'; + const parent2 = GitSha.from('abcdef1234567890abcdef1234567890abcdef12'); + + const builder = new GitCommitBuilder(); + const commit = builder + .tree(tree) + .author(author) + .committer(committer) + .parent(parent1) + .parent(parent2) + .build(); + + expect(commit.parents).toHaveLength(2); + expect(commit.parents[0].toString()).toBe(parent1); + expect(commit.parents[1].equals(parent2)).toBe(true); + }); +}); \ No newline at end of file diff --git a/test/domain/entities/GitTree.test.js b/test/domain/entities/GitTree.test.js new file mode 100644 index 0000000..51d053b --- /dev/null +++ b/test/domain/entities/GitTree.test.js @@ -0,0 +1,42 @@ +import GitTree from '../../../src/domain/entities/GitTree.js'; +import GitTreeEntry from '../../../src/domain/entities/GitTreeEntry.js'; +import GitSha from '../../../src/domain/value-objects/GitSha.js'; + +describe('GitTree', () => { + const VALID_SHA = 'a1b2c3d4e5f67890123456789012345678901234'; + const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; + + it('serializes to mktree format', () => { + const entry1 = new GitTreeEntry({ + path: 'file.txt', + sha: GitSha.from(VALID_SHA), + mode: '100644' + }); + const entry2 = new GitTreeEntry({ + path: 'subdir', + sha: GitSha.from(EMPTY_TREE_SHA), + mode: '040000' + }); + const tree = new GitTree(null, [entry1, entry2]); + + const format = tree.toMktreeFormat(); + expect(format).toBe(`100644 blob ${VALID_SHA}\tfile.txt\n040000 tree ${EMPTY_TREE_SHA}\tsubdir\n`); + }); + + it('returns single newline for empty tree mktree format', () => { + const tree = new GitTree(null, []); + expect(tree.toMktreeFormat()).toBe('\n'); + }); + + it('can be created from data', () => { + const data = { + sha: VALID_SHA, + entries: [ + { path: 'f.txt', sha: VALID_SHA, mode: '100644' } + ] + }; + const tree = GitTree.fromData(data); + expect(tree.sha.toString()).toBe(VALID_SHA); + expect(tree.entries[0].path).toBe('f.txt'); + }); +}); diff --git a/test/domain/entities/GitTreeBuilder.test.js b/test/domain/entities/GitTreeBuilder.test.js new file mode 100644 index 0000000..95963ba --- /dev/null +++ b/test/domain/entities/GitTreeBuilder.test.js @@ -0,0 +1,29 @@ +import GitTreeBuilder from '../../../src/domain/entities/GitTreeBuilder.js'; +import GitTree from '../../../src/domain/entities/GitTree.js'; +import GitSha from '../../../src/domain/value-objects/GitSha.js'; +import GitFileMode from '../../../src/domain/value-objects/GitFileMode.js'; + +describe('GitTreeBuilder', () => { + const sha = GitSha.EMPTY_TREE; + + it('builds a tree with multiple entries', () => { + const builder = new GitTreeBuilder(); + builder.add({ path: 'file1.txt', sha, mode: GitFileMode.REGULAR }); + builder.add({ path: 'file2.txt', sha, mode: GitFileMode.EXECUTABLE }); + + const tree = builder.build(); + expect(tree).toBeInstanceOf(GitTree); + expect(tree.entries).toHaveLength(2); + expect(tree.entries[0].path).toBe('file1.txt'); + expect(tree.entries[1].mode.isExecutable()).toBe(true); + }); + + it('is fluent', () => { + const tree = new GitTreeBuilder() + .add({ path: 'a', sha, mode: GitFileMode.REGULAR }) + .add({ path: 'b', sha, mode: GitFileMode.REGULAR }) + .build(); + + expect(tree.entries).toHaveLength(2); + }); +}); diff --git a/test/domain/entities/GitTreeEntry.test.js b/test/domain/entities/GitTreeEntry.test.js new file mode 100644 index 0000000..83e185a --- /dev/null +++ b/test/domain/entities/GitTreeEntry.test.js @@ -0,0 +1,27 @@ +import GitTreeEntry from '../../../src/domain/entities/GitTreeEntry.js'; +import GitSha from '../../../src/domain/value-objects/GitSha.js'; +import GitFileMode from '../../../src/domain/value-objects/GitFileMode.js'; +import ValidationError from '../../../src/domain/errors/ValidationError.js'; + +describe('GitTreeEntry', () => { + const sha = GitSha.EMPTY_TREE; + const regularMode = new GitFileMode(GitFileMode.REGULAR); + const treeMode = new GitFileMode(GitFileMode.TREE); + + it('creates a valid entry', () => { + const entry = new GitTreeEntry({ mode: regularMode, sha, path: 'file.txt' }); + expect(entry.mode).toBe(regularMode); + expect(entry.sha).toBe(sha); + expect(entry.path).toBe('file.txt'); + }); + + it('throws for invalid SHA', () => { + expect(() => new GitTreeEntry({ mode: regularMode, sha: 'not-a-sha', path: 'file.txt' })).toThrow(ValidationError); + }); + + it('identifies tree correctly', () => { + const entry = new GitTreeEntry({ mode: treeMode, sha, path: 'dir' }); + expect(entry.isTree()).toBe(true); + expect(entry.isBlob()).toBe(false); + }); +}); \ No newline at end of file diff --git a/test/domain/errors/Errors.test.js b/test/domain/errors/Errors.test.js new file mode 100644 index 0000000..64aa3aa --- /dev/null +++ b/test/domain/errors/Errors.test.js @@ -0,0 +1,34 @@ + +import GitPlumbingError from '../../../src/domain/errors/GitPlumbingError.js'; +import ValidationError from '../../../src/domain/errors/ValidationError.js'; +import InvalidArgumentError from '../../../src/domain/errors/InvalidArgumentError.js'; +import InvalidGitObjectTypeError from '../../../src/domain/errors/InvalidGitObjectTypeError.js'; + +describe('Custom Errors', () => { + it('GitPlumbingError has correct properties', () => { + const error = new GitPlumbingError('message', 'op', { foo: 'bar' }); + expect(error.message).toBe('message'); + expect(error.operation).toBe('op'); + expect(error.details).toEqual({ foo: 'bar' }); + expect(error.name).toBe('GitPlumbingError'); + expect(error).toBeInstanceOf(Error); + }); + + it('ValidationError inherits from GitPlumbingError', () => { + const error = new ValidationError('invalid', 'op'); + expect(error).toBeInstanceOf(GitPlumbingError); + expect(error.name).toBe('ValidationError'); + }); + + it('InvalidArgumentError inherits from GitPlumbingError', () => { + const error = new InvalidArgumentError('bad arg', 'op'); + expect(error).toBeInstanceOf(GitPlumbingError); + expect(error.name).toBe('InvalidArgumentError'); + }); + + it('InvalidGitObjectTypeError has specific message', () => { + const error = new InvalidGitObjectTypeError('blobby', 'op'); + expect(error.message).toBe('Invalid Git object type: blobby'); + expect(error.details.type).toBe('blobby'); + }); +}); diff --git a/test/domain/services/ByteMeasurer.test.js b/test/domain/services/ByteMeasurer.test.js new file mode 100644 index 0000000..fb3534e --- /dev/null +++ b/test/domain/services/ByteMeasurer.test.js @@ -0,0 +1,14 @@ + +import ByteMeasurer from '../../../src/domain/services/ByteMeasurer.js'; + +describe('ByteMeasurer', () => { + it('measures string length in bytes (UTF-8)', () => { + expect(ByteMeasurer.measure('Hello')).toBe(5); + expect(ByteMeasurer.measure('๐Ÿš€')).toBe(4); // Emoji is 4 bytes + }); + + it('measures Uint8Array length', () => { + const data = new Uint8Array([1, 2, 3, 4]); + expect(ByteMeasurer.measure(data)).toBe(4); + }); +}); diff --git a/test/domain/services/CommandSanitizer.test.js b/test/domain/services/CommandSanitizer.test.js new file mode 100644 index 0000000..111b95d --- /dev/null +++ b/test/domain/services/CommandSanitizer.test.js @@ -0,0 +1,83 @@ +import CommandSanitizer from '../../../src/domain/services/CommandSanitizer.js'; +import ValidationError from '../../../src/domain/errors/ValidationError.js'; +import ProhibitedFlagError from '../../../src/domain/errors/ProhibitedFlagError.js'; + +describe('CommandSanitizer', () => { + let sanitizer; + + beforeEach(() => { + sanitizer = new CommandSanitizer(); + }); + + it('allows whitelisted commands', () => { + expect(() => sanitizer.sanitize(['rev-parse', 'HEAD'])).not.toThrow(); + }); + + it('throws ValidationError for unlisted commands', () => { + expect(() => sanitizer.sanitize(['push', 'origin', 'main'])).toThrow(ValidationError); + }); + + it('throws ProhibitedFlagError for banned flags', () => { + expect(() => sanitizer.sanitize(['rev-parse', '--work-tree=/tmp', 'HEAD'])).toThrow(ProhibitedFlagError); + }); + + it('blocks global flags before the subcommand', () => { + expect(() => sanitizer.sanitize(['-C', '/tmp', 'rev-parse', 'HEAD'])).toThrow(ProhibitedFlagError); + expect(() => sanitizer.sanitize(['-c', 'user.name=attacker', 'rev-parse', 'HEAD'])).toThrow(ProhibitedFlagError); + expect(() => sanitizer.sanitize(['--git-dir=/tmp/.git', 'rev-parse', 'HEAD'])).toThrow(ProhibitedFlagError); + + try { + sanitizer.sanitize(['-C', '/tmp', 'rev-parse', 'HEAD']); + } catch (err) { + expect(err.message).toContain('Global flag "-C" is prohibited before the subcommand'); + } + }); + + it('allows whitelisted commands even if preceded by non-prohibited flags', () => { + // Note: --version is technically a command in our whitelist, but also a flag. + // In git, 'git --version' works. + expect(() => sanitizer.sanitize(['--version'])).not.toThrow(); + }); + + it('allows dynamic registration of commands', () => { + // Reset allowed commands to a known state for this test if needed, + // but here we just test adding one. + const testCmd = 'test-command-' + Math.random(); + expect(() => sanitizer.sanitize([testCmd])).toThrow(ValidationError); + CommandSanitizer.allow(testCmd); + expect(() => sanitizer.sanitize([testCmd])).not.toThrow(); + }); + + it('uses memoization to skip re-validation', () => { + const args = ['rev-parse', 'HEAD']; + + // First time + sanitizer.sanitize(args); + + // We can't easily swap _ALLOWED_COMMANDS because it's static and used by the instance. + // But we can test that it doesn't throw even if we theoretically "broke" the static rules + // (though in JS it's hard to mock static members cleanly without affecting everything). + + // Instead, let's just verify it returns the same args and doesn't re-run expensive logic + // (though we can't easily see internal state here without more instrumentation). + const result = sanitizer.sanitize(args); + expect(result).toBe(args); + }); + + it('handles cache eviction', () => { + const smallSanitizer = new CommandSanitizer({ maxCacheSize: 2 }); + + const args1 = ['rev-parse', 'HEAD']; + const args2 = ['cat-file', '-p', '4b825dc642cb6eb9a060e54bf8d69288fbee4904']; + const args3 = ['ls-tree', 'HEAD']; + + smallSanitizer.sanitize(args1); + smallSanitizer.sanitize(args2); + + // This should evict args1 + smallSanitizer.sanitize(args3); + + // Since we can't easily break the static whitelist for one test, + // we trust the implementation of Map and LRU logic. + }); +}); \ No newline at end of file diff --git a/test/domain/services/EnvironmentPolicy.test.js b/test/domain/services/EnvironmentPolicy.test.js new file mode 100644 index 0000000..a3a99e4 --- /dev/null +++ b/test/domain/services/EnvironmentPolicy.test.js @@ -0,0 +1,57 @@ +import EnvironmentPolicy from '../../../src/domain/services/EnvironmentPolicy.js'; + +describe('EnvironmentPolicy', () => { + it('filters out non-whitelisted environment variables', () => { + const env = { + PATH: '/usr/bin', + DANGEROUS_VAR: 'hack', + GIT_AUTHOR_NAME: 'James Ross', + LANG: 'en_US.UTF-8' + }; + + const filtered = EnvironmentPolicy.filter(env); + + expect(filtered).toEqual({ + PATH: '/usr/bin', + GIT_AUTHOR_NAME: 'James Ross', + LANG: 'en_US.UTF-8' + }); + expect(filtered.DANGEROUS_VAR).toBeUndefined(); + }); + + it('explicitly blocks GIT_CONFIG_PARAMETERS', () => { + const env = { + GIT_AUTHOR_NAME: 'James Ross', + GIT_CONFIG_PARAMETERS: "'user.name=attacker'" + }; + + const filtered = EnvironmentPolicy.filter(env); + + expect(filtered.GIT_AUTHOR_NAME).toBe('James Ross'); + expect(filtered.GIT_CONFIG_PARAMETERS).toBeUndefined(); + }); + + it('includes all requested identity and localization variables', () => { + const env = { + GIT_AUTHOR_NAME: 'name', + GIT_AUTHOR_EMAIL: 'email', + GIT_AUTHOR_DATE: 'date', + GIT_COMMITTER_NAME: 'cname', + GIT_COMMITTER_EMAIL: 'cemail', + GIT_COMMITTER_DATE: 'cdate', + LANG: 'lang', + LC_ALL: 'all', + LC_CTYPE: 'ctype', + LC_MESSAGES: 'messages' + }; + + const filtered = EnvironmentPolicy.filter(env); + + expect(filtered).toEqual(env); + }); + + it('handles empty or undefined environment', () => { + expect(EnvironmentPolicy.filter({})).toEqual({}); + expect(EnvironmentPolicy.filter(undefined)).toEqual({}); + }); +}); \ No newline at end of file diff --git a/test/domain/services/ExecutionOrchestrator.test.js b/test/domain/services/ExecutionOrchestrator.test.js new file mode 100644 index 0000000..86ff7bc --- /dev/null +++ b/test/domain/services/ExecutionOrchestrator.test.js @@ -0,0 +1,68 @@ +import ExecutionOrchestrator from '../../../src/domain/services/ExecutionOrchestrator.js'; +import CommandRetryPolicy from '../../../src/domain/value-objects/CommandRetryPolicy.js'; +import GitPlumbingError from '../../../src/domain/errors/GitPlumbingError.js'; +import GitErrorClassifier from '../../../src/domain/services/GitErrorClassifier.js'; +import GitRepositoryLockedError from '../../../src/domain/errors/GitRepositoryLockedError.js'; + +describe('ExecutionOrchestrator', () => { + it('respects totalTimeout even if retries are remaining', async () => { + const orchestrator = new ExecutionOrchestrator(); + const policy = new CommandRetryPolicy({ + maxAttempts: 10, + initialDelayMs: 100, + totalTimeout: 200 // Very short timeout + }); + + const execute = async () => { + // Simulate work taking longer than timeout + await new Promise(resolve => setTimeout(resolve, 300)); + return { stdout: 'done', result: { code: 0, stderr: '' } }; + }; + + await expect(orchestrator.orchestrate({ + execute, + retryPolicy: policy, + args: ['test'], + traceId: 'trace' + })).rejects.toThrow(GitPlumbingError); + }); + + it('aborts retries if backoff would exceed totalTimeout', async () => { + // Mock classifier to always return a retryable error + const classifier = new GitErrorClassifier(); + const orchestrator = new ExecutionOrchestrator({ classifier }); + + const policy = new CommandRetryPolicy({ + maxAttempts: 3, + initialDelayMs: 500, // Delay for 2nd retry + totalTimeout: 600 // Short total timeout + }); + + let attempts = 0; + const execute = async () => { + attempts++; + return { + stdout: '', + result: { code: 128, stderr: 'index.lock' } + }; + }; + + let error; + try { + await orchestrator.orchestrate({ + execute, + retryPolicy: policy, + args: ['test'], + traceId: 'trace' + }); + } catch (err) { + error = err; + } + + expect(error).toBeInstanceOf(GitRepositoryLockedError); + // With getDelay(attempt): + // Attempt 1 fails. backoff = getDelay(1) = 0. 0 < 600. Proceeds to Attempt 2. + // Attempt 2 fails. backoff = getDelay(2) = 500. elapsed + 500 > 600. Aborts. + expect(attempts).toBe(2); + }); +}); \ No newline at end of file diff --git a/test/domain/services/GitCommandBuilder.test.js b/test/domain/services/GitCommandBuilder.test.js new file mode 100644 index 0000000..72ed54a --- /dev/null +++ b/test/domain/services/GitCommandBuilder.test.js @@ -0,0 +1,59 @@ +import GitCommandBuilder from '../../../src/domain/services/GitCommandBuilder.js'; + +describe('GitCommandBuilder', () => { + it('builds a hash-object command with flags', () => { + const args = GitCommandBuilder.hashObject() + .write() + .stdin() + .build(); + + expect(args).toEqual(['hash-object', '-w', '--stdin']); + }); + + it('builds a cat-file command with pretty-print', () => { + const args = GitCommandBuilder.catFile() + .pretty() + .arg('HEAD') + .build(); + + expect(args).toEqual(['cat-file', '-p', 'HEAD']); + }); + + it('builds a commit-tree command with message and parents', () => { + const treeSha = 'a'.repeat(40); + const parent1 = 'b'.repeat(40); + const parent2 = 'c'.repeat(40); + + const args = GitCommandBuilder.commitTree() + .arg(treeSha) + .parent(parent1) + .parent(parent2) + .message('Initial commit') + .build(); + + expect(args).toEqual([ + 'commit-tree', + treeSha, + '-p', parent1, + '-p', parent2, + '-m', 'Initial commit' + ]); + }); + + it('builds an update-ref command with delete', () => { + const args = GitCommandBuilder.updateRef() + .delete() + .arg('refs/heads/main') + .build(); + + expect(args).toEqual(['update-ref', '-d', 'refs/heads/main']); + }); + + it('handles all static factory methods', () => { + // Spot check a few more + expect(GitCommandBuilder.lsTree().build()).toEqual(['ls-tree']); + expect(GitCommandBuilder.revList().build()).toEqual(['rev-list']); + expect(GitCommandBuilder.init().build()).toEqual(['init']); + expect(GitCommandBuilder.version().build()).toEqual(['--version']); + }); +}); diff --git a/test/domain/services/GitErrorClassifier.test.js b/test/domain/services/GitErrorClassifier.test.js new file mode 100644 index 0000000..8f8a1d9 --- /dev/null +++ b/test/domain/services/GitErrorClassifier.test.js @@ -0,0 +1,77 @@ +import GitErrorClassifier from '../../../src/domain/services/GitErrorClassifier.js'; +import GitRepositoryLockedError from '../../../src/domain/errors/GitRepositoryLockedError.js'; +import GitPlumbingError from '../../../src/domain/errors/GitPlumbingError.js'; + +describe('GitErrorClassifier', () => { + const classifier = new GitErrorClassifier(); + const baseOptions = { + args: ['rev-parse', 'HEAD'], + traceId: 'test-trace', + latency: 100, + operation: 'test-op' + }; + + it('classifies index.lock as a locked error', () => { + const error = classifier.classify({ + ...baseOptions, + code: 128, + stderr: 'fatal: Unable to create \'/path/to/.git/index.lock\': File exists.' + }); + + expect(error).toBeInstanceOf(GitRepositoryLockedError); + expect(classifier.isRetryable(error)).toBe(true); + }); + + it('classifies other .lock files as locked errors', () => { + const error = classifier.classify({ + ...baseOptions, + code: 128, + stderr: 'fatal: Unable to create \'/path/to/.git/refs/heads/main.lock\': File exists.' + }); + + expect(error).toBeInstanceOf(GitRepositoryLockedError); + }); + + it('allows injecting custom rules via constructor', () => { + class CustomError extends Error { constructor(msg) { super(msg); this.name = 'CustomError'; } } + + const customClassifier = new GitErrorClassifier({ + customRules: [{ + test: (code, _stderr) => code === 42, + create: (_opts) => new CustomError('Meaning of life error') + }] + }); + + const error = customClassifier.classify({ + ...baseOptions, + code: 42, + stderr: 'The universe exploded' + }); + + expect(error.name).toBe('CustomError'); + expect(error.message).toBe('Meaning of life error'); + }); + + it('classifies generic failures as GitPlumbingError', () => { + const error = classifier.classify({ + ...baseOptions, + code: 1, + stderr: "error: unknown option `foo'" + }); + + expect(error).toBeInstanceOf(GitPlumbingError); + expect(error).not.toBeInstanceOf(GitRepositoryLockedError); + expect(classifier.isRetryable(error)).toBe(false); + }); + + it('classifies code 128 without lock message as GitPlumbingError', () => { + const error = classifier.classify({ + ...baseOptions, + code: 128, + stderr: 'fatal: not a git repository' + }); + + expect(error).toBeInstanceOf(GitPlumbingError); + expect(error).not.toBeInstanceOf(GitRepositoryLockedError); + }); +}); diff --git a/test/domain/services/GitPersistenceService.test.js b/test/domain/services/GitPersistenceService.test.js new file mode 100644 index 0000000..668be8f --- /dev/null +++ b/test/domain/services/GitPersistenceService.test.js @@ -0,0 +1,80 @@ +import GitPersistenceService from '../../../src/domain/services/GitPersistenceService.js'; +import GitPlumbing from '../../../index.js'; +import GitBlob from '../../../src/domain/entities/GitBlob.js'; +import GitTree from '../../../src/domain/entities/GitTree.js'; +import GitTreeEntry from '../../../src/domain/entities/GitTreeEntry.js'; +import GitCommit from '../../../src/domain/entities/GitCommit.js'; +import GitSha from '../../../src/domain/value-objects/GitSha.js'; +import GitSignature from '../../../src/domain/value-objects/GitSignature.js'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; + +describe('GitPersistenceService', () => { + let git; + let persistence; + let repoPath; + + beforeAll(async () => { + repoPath = path.join(os.tmpdir(), `git-persistence-test-${Math.random().toString(36).substring(7)}`); + fs.mkdirSync(repoPath, { recursive: true }); + + git = GitPlumbing.createDefault({ cwd: repoPath }); + persistence = new GitPersistenceService({ plumbing: git }); + + await git.execute({ args: ['init'] }); + }); + + afterAll(() => { + fs.rmSync(repoPath, { recursive: true, force: true }); + }); + + it('writes a blob', async () => { + const blob = GitBlob.fromContent('Persistence test'); + const sha = await persistence.writeBlob(blob); + + expect(sha).toBeInstanceOf(GitSha); + const content = await git.execute({ args: ['cat-file', '-p', sha.toString()] }); + expect(content).toBe('Persistence test'); + }); + + it('writes a tree', async () => { + const blobSha = await persistence.writeBlob(GitBlob.fromContent('file content')); + const entry = new GitTreeEntry({ path: 'test.txt', sha: blobSha, mode: '100644' }); + const tree = new GitTree(null, [entry]); + + const treeSha = await persistence.writeTree(tree); + expect(treeSha).toBeInstanceOf(GitSha); + + const lsTree = await git.execute({ args: ['ls-tree', treeSha.toString()] }); + expect(lsTree).toContain('test.txt'); + }); + + it('writes a commit', async () => { + const blobSha = await persistence.writeBlob(GitBlob.fromContent('file content')); + const entry = new GitTreeEntry({ path: 'test.txt', sha: blobSha, mode: '100644' }); + const treeSha = await persistence.writeTree(new GitTree(null, [entry])); + + const signature = new GitSignature({ + name: 'James', + email: 'james@test.com', + timestamp: 1234567890 + }); + + const commit = new GitCommit({ + sha: null, + treeSha, + parents: [], + author: signature, + committer: signature, + message: 'Persistence commit' + }); + + const commitSha = await persistence.writeCommit(commit); + expect(commitSha).toBeInstanceOf(GitSha); + + const cat = await git.execute({ args: ['cat-file', '-p', commitSha.toString()] }); + expect(cat).toContain('Persistence commit'); + expect(cat).toContain('author James 1234567890'); + }); +}); diff --git a/test/domain/value-objects/GitFileMode.test.js b/test/domain/value-objects/GitFileMode.test.js new file mode 100644 index 0000000..4377b73 --- /dev/null +++ b/test/domain/value-objects/GitFileMode.test.js @@ -0,0 +1,30 @@ + +import GitFileMode from '../../../src/domain/value-objects/GitFileMode.js'; +import ValidationError from '../../../src/domain/errors/ValidationError.js'; + +describe('GitFileMode', () => { + describe('constructor', () => { + it('creates a valid GitFileMode', () => { + const mode = new GitFileMode(GitFileMode.REGULAR); + expect(mode.toString()).toBe('100644'); + }); + + it('throws ValidationError for invalid mode', () => { + expect(() => new GitFileMode('999999')).toThrow(ValidationError); + }); + }); + + describe('is methods', () => { + it('correctly identifies tree', () => { + const mode = new GitFileMode(GitFileMode.TREE); + expect(mode.isTree()).toBe(true); + expect(mode.isRegular()).toBe(false); + }); + + it('correctly identifies regular file', () => { + const mode = new GitFileMode(GitFileMode.REGULAR); + expect(mode.isRegular()).toBe(true); + expect(mode.isExecutable()).toBe(false); + }); + }); +}); diff --git a/test/domain/value-objects/GitObjectType.test.js b/test/domain/value-objects/GitObjectType.test.js new file mode 100644 index 0000000..972fd5f --- /dev/null +++ b/test/domain/value-objects/GitObjectType.test.js @@ -0,0 +1,65 @@ + +import GitObjectType from '../../../src/domain/value-objects/GitObjectType.js'; +import InvalidGitObjectTypeError from '../../../src/domain/errors/InvalidGitObjectTypeError.js'; + +describe('GitObjectType', () => { + describe('constructor', () => { + it('creates a valid GitObjectType from a valid number', () => { + const type = new GitObjectType(GitObjectType.BLOB_INT); + expect(type.toNumber()).toBe(GitObjectType.BLOB_INT); + expect(type.toString()).toBe(GitObjectType.BLOB); + }); + + it('throws InvalidGitObjectTypeError for invalid number', () => { + expect(() => new GitObjectType(99)).toThrow(InvalidGitObjectTypeError); + }); + }); + + describe('static fromString', () => { + it('creates a valid GitObjectType from a valid string', () => { + const type = GitObjectType.fromString(GitObjectType.TREE); + expect(type.toNumber()).toBe(GitObjectType.TREE_INT); + expect(type.toString()).toBe(GitObjectType.TREE); + }); + + it('throws InvalidGitObjectTypeError for invalid string', () => { + expect(() => GitObjectType.fromString('invalid')).toThrow(InvalidGitObjectTypeError); + }); + }); + + describe('equals', () => { + it('returns true for equal types', () => { + const type1 = GitObjectType.blob(); + const type2 = GitObjectType.fromString('blob'); + expect(type1.equals(type2)).toBe(true); + }); + + it('returns false for different types', () => { + const type1 = GitObjectType.blob(); + const type2 = GitObjectType.tree(); + expect(type1.equals(type2)).toBe(false); + }); + }); + + describe('is methods', () => { + it('correctly identifies blob', () => { + expect(GitObjectType.blob().isBlob()).toBe(true); + expect(GitObjectType.blob().isTree()).toBe(false); + }); + + it('correctly identifies tree', () => { + expect(GitObjectType.tree().isTree()).toBe(true); + expect(GitObjectType.tree().isCommit()).toBe(false); + }); + + it('correctly identifies commit', () => { + expect(GitObjectType.commit().isCommit()).toBe(true); + expect(GitObjectType.commit().isTag()).toBe(false); + }); + + it('correctly identifies tag', () => { + expect(GitObjectType.tag().isTag()).toBe(true); + expect(GitObjectType.tag().isBlob()).toBe(false); + }); + }); +});