diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7bfffcd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,202 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + quality: + name: Lint and coverage gate + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install TeaForge and development dependencies + run: python -m pip install -e ".[dev]" + - name: Check Python source quality + run: python -m ruff check src tests demo + - name: Enforce TeaForge branch coverage baseline + run: | + python -m coverage run -m pytest -q + python -m coverage report + + python-tests: + name: ${{ matrix.os }} / Python ${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + python-version: "3.11" + - os: ubuntu-latest + python-version: "3.14" + - os: windows-latest + python-version: "3.12" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install TeaForge and test dependencies + run: python -m pip install -e ".[dev]" + - name: Run tests + run: python -m pytest -q + - name: Compile Python modules + run: python -m compileall -q src tests + + package-smoke: + name: Wheel and sdist smoke + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Build and validate distributions + run: | + python -m pip install build twine + python -m build + python -m twine check dist/* + - name: Install wheel outside the source tree + run: | + python -m venv "$RUNNER_TEMP/teaforge-smoke" + "$RUNNER_TEMP/teaforge-smoke/bin/pip" install dist/*.whl + cd "$RUNNER_TEMP" + "$RUNNER_TEMP/teaforge-smoke/bin/teaforge" --version + "$RUNNER_TEMP/teaforge-smoke/bin/teaforge" pcl generate \ + --path "$GITHUB_WORKSPACE/tests/fixtures/test_sample_pytest.py" \ + --output "$RUNNER_TEMP/pcl.html" + "$RUNNER_TEMP/teaforge-smoke/bin/teaforge" coverage generate \ + --path "$GITHUB_WORKSPACE/tests/fixtures/coverage_project/tests" \ + --output "$RUNNER_TEMP/coverage.html" \ + --python-executable "$RUNNER_TEMP/teaforge-smoke/bin/python" \ + --min-c0 100 + "$RUNNER_TEMP/teaforge-smoke/bin/python" -c \ + "from importlib import resources; assert resources.files('teaforge.templates').joinpath('pcl.html').is_file(); assert resources.files('teaforge.templates').joinpath('coverage_report.html').is_file(); assert resources.files('teaforge.jest.assets').joinpath('runtime-listener.cjs').is_file()" + - uses: actions/upload-artifact@v4 + with: + name: teaforge-distributions + path: dist/* + + jest-integration: + name: Jest ${{ matrix.node-version }} integration + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + node-version: ["20", "22"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + cache-dependency-path: demo/jest_runtime/package-lock.json + - name: Install TeaForge and locked Jest fixture + run: | + python -m pip install -e . + npm ci --prefix demo/jest_runtime + - name: Run target project tests + run: npm --prefix demo/jest_runtime test -- --runInBand + - name: Generate runtime evidence outside the project directory + run: | + cd "$RUNNER_TEMP" + teaforge doctor \ + --framework jest \ + --path "$GITHUB_WORKSPACE/demo/jest_runtime/tests/user.test.js" \ + --json + teaforge pcl generate \ + --framework jest \ + --evidence-mode runtime \ + --path "$GITHUB_WORKSPACE/demo/jest_runtime/tests/user.test.js" \ + --output "$RUNNER_TEMP/jest-pcl.html" + teaforge coverage generate \ + --framework jest \ + --path "$GITHUB_WORKSPACE/demo/jest_runtime/tests/user.test.js" \ + --output "$RUNNER_TEMP/jest-coverage.html" \ + --min-c0 100 \ + --min-c1 100 + - name: Verify runtime evidence identity and status + run: | + python -c "import json, pathlib; files=list(pathlib.Path('$RUNNER_TEMP/user').glob('jest-pcl-*.json')); docs=[json.loads(path.read_text()) for path in files]; serialized=json.dumps(docs); assert {doc['method'] for doc in docs} == {'createUser', 'getUser'}; assert all(case['execution_status'] == 'passed' for doc in docs for case in doc['testcases']); assert 'demo-secret' not in serialized; assert '[REDACTED]' in serialized" + python -c "import json, pathlib, re; html=pathlib.Path('$RUNNER_TEMP/jest-coverage.html').read_text(); payload=json.loads(re.search(r'', html, re.S).group(1)); assert payload['schema_version'] == 2; assert payload['evidence_source'] == 'Jest/Istanbul coverage-final.json'; assert payload['c0']['percent'] == 100.0; assert payload['c1']['percent'] == 100.0" + + render-integration: + name: Mermaid and PDF integration + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + TEAFORGE_MERMAID_PUPPETEER_CONFIG: ${{ github.workspace }}/tools/render/puppeteer-ci.json + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: tools/render/package-lock.json + - name: Install TeaForge and locked render tools + run: | + sudo apt-get update + sudo apt-get install -y libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0 + python -m pip install -e ".[dev,pdf]" + npm ci --prefix tools/render + echo "$GITHUB_WORKSPACE/tools/render/node_modules/.bin" >> "$GITHUB_PATH" + - name: Check required render capabilities + run: teaforge doctor --framework pytest --path demo/fastapi_crud/tests --require-mermaid --require-pdf + - name: Render flowchart, sequence, coverage, and PDF + run: | + teaforge mermaid generate \ + --source demo/fastapi_crud/app/main.py \ + --function create_item \ + --diagram-type flowchart \ + --code "flowchart TD + A[Receive request] --> B{Valid?} + B -- Yes --> C[Create item] + B -- No --> D[Return validation error]" \ + --output-dir "$RUNNER_TEMP/diagrams" + teaforge mermaid generate \ + --source demo/fastapi_crud/app/main.py \ + --function create_item \ + --diagram-type sequence \ + --code "sequenceDiagram + Client->>API: Create item + API->>DB: Insert item + DB-->>API: Stored row + API-->>Client: Created response" \ + --output-dir "$RUNNER_TEMP/diagrams" + teaforge coverage generate \ + --path demo/fastapi_crud/tests \ + --output "$RUNNER_TEMP/render-coverage.html" \ + --function create_item \ + --sequence create_item \ + --diagram-dir "$RUNNER_TEMP/diagrams" \ + --min-c0 100 \ + --min-c1 100 + teaforge export \ + --path "$RUNNER_TEMP/render-coverage.html" \ + --output "$RUNNER_TEMP/render-coverage.pdf" + test -s "$RUNNER_TEMP/diagrams/main_create_item_coverage_report.svg" + test -s "$RUNNER_TEMP/diagrams/main_create_item_sequence_diagram.svg" + python -c "from pathlib import Path; assert Path('$RUNNER_TEMP/render-coverage.pdf').read_bytes().startswith(b'%PDF')" diff --git a/.gitignore b/.gitignore index 54d8b64..32f8a4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,15 @@ .pytest_cache/ .ruff_cache/ +.coverage +.coverage.* __pycache__/ *.pyc *.pyo *.pyd .DS_Store .venv/ +node_modules/ +.playwright-cli/ dist/ build/ *.egg-info/ @@ -14,6 +18,16 @@ demo/fastapi_crud/*.db *.html *.json +# The executable Jest integration fixture must remain reproducible. +!demo/jest_runtime/package.json +!demo/jest_runtime/package-lock.json +!tools/render/package.json +!tools/render/package-lock.json +!tools/render/puppeteer-ci.json + +# Report templates are source files, not generated output. +!src/teaforge/templates/*.html + # Ignore all files under output/ but keep the directory with a .gitkeep output/* !output/.gitkeep @@ -21,4 +35,3 @@ output/* # Internal development docs STEP*.md DESIGN.md - diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..33bd00c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +## 0.2.0 - 2026-07-15 + +TeaForge moves from an early pytest document demo to a beta-quality, installable CLI. + +### Added + +- Versioned PCL and coverage artifacts for pytest, Jest, Angular/Jest, and Playwright tests. +- Project-local Jest runtime assertion evidence with expected/actual values, pass/fail state, redaction, and bounded JSONL capture. +- Tree-sitter JavaScript, TypeScript, and TSX syntax evidence shared by parsers and coverage analysis. +- File-level C0/C1 gates, Mermaid flowchart and sequence-diagram report pages, and optional PDF export. +- `teaforge doctor` capability checks and machine-readable JSON output. +- Linux, Windows, Python 3.11-3.14, Node 20/22, packaging, lint, branch-coverage, Jest, Mermaid, and PDF CI gates. + +### Changed + +- External processes now share monotonic workflow deadlines, retain bounded diagnostics, and clean up descendant processes on timeout. +- Pytest and Jest run from discovered target-project roots without downloading target dependencies. +- Test discovery excludes generated dependency and virtual-environment trees by default. +- Text artifacts are rendered before same-directory atomic replacement. + +### Security + +- Runtime evidence redacts common credential fields and patterns and enforces value, record, and file-size limits. +- External commands execute without a shell and use exact resolved runner and test paths. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..22a3fdf --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,61 @@ +# TeaForge Domain Context + +TeaForge converts executable test evidence into reviewable engineering artifacts. The implementation should use these terms consistently in code, CLI help, reports, and architecture decisions. + +## Core terms + +### Test Subject + +The source file and function or method whose behavior a test exercises. A subject identity is proven from imports and calls when possible. Coverage generation must fail when source identity cannot be proven; PCL generation may preserve an explicitly marked fallback identity. + +### PCL Document + +A Program Check List for one Test Subject. It contains design-time inputs and checks, optional runtime evidence, source identity, execution status, warnings, and a stable artifact schema version. One document may be split into multiple 25-column sheets without changing its subject identity. + +### Static Evidence + +Inputs and expectations inferred without executing the target tests. Static evidence describes test intent, but can be incomplete when values are built dynamically. + +### JavaScript Syntax Evidence + +Structural Static Evidence produced from TeaForge-packaged Tree-sitter JavaScript, TypeScript, and TSX grammars. Adapters consume stable imports, test cases, calls, exported symbols, and source-function facts rather than grammar nodes. It proves syntax structure, not TypeScript types or runtime values. + +### Runtime Evidence + +Matcher, expected value, observed actual value, and pass/fail status captured while an already-installed target-project Jest runs. Runtime Evidence supplements rather than replaces Static Evidence. + +### Coverage Evidence + +Executed and missing statement/branch locations read from Python coverage data or Istanbul JSON. Coverage Evidence is distinct from assertion evidence and must remain traceable to its source file. + +### Flowchart + +A typed Mermaid diagram of internal control flow for a Test Subject: decisions, loops, error paths, and exits. + +### Sequence Diagram + +A typed Mermaid diagram of interaction order across participants such as caller, service, persistence, and external systems. TeaForge does not infer a trustworthy sequence solely from assertion values; the caller supplies the reviewed diagram source. + +### Artifact + +A versioned JSON, HTML, PDF, Mermaid, or SVG output produced from evidence. Text artifacts are written through same-directory temporary files and atomic replacement. Concurrent writers to the same path are outside the current contract. + +### Capability Check + +A non-mutating `teaforge doctor` check that reports whether a required executable, Python module, packaged resource, renderer, or target-project discovery rule is satisfied. + +## Invariants + +1. TeaForge never downloads or installs target-project test runners during analysis. +2. Target tests and renderers execute from their discovered context with exact paths, one workflow deadline, memory-bounded captured output, and process-tree cleanup on timeout. +3. A failed test and a failed TeaForge invocation are different results. Evidence-bearing Jest failures use exit code 2. +4. Runtime Evidence never silently overwrites the test's Static Evidence. +5. Unsupported or ambiguous diagram types and source identities fail explicitly. +6. New artifact schemas are versioned; unknown future versions are rejected rather than guessed. +7. Machine-readable command modes emit versioned JSON for both success and post-dispatch failure paths. + +## Current boundaries + +JavaScript/TypeScript discovery uses structural Tree-sitter grammars rather than regex matching, but it is not a TypeScript type checker and does not evaluate dynamic imports or computed test construction. Runtime evidence has default sensitive-key and common credential-pattern redaction plus size limits, but each organization must decide whether that policy is sufficient. File-level C0/C1 gates are available; organization-wide aggregation and policy profiles remain outside the current contract. + +Directory discovery excludes generated dependency and environment trees by default. An explicitly supplied file remains eligible even when it is located under an excluded directory. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..b6ba291 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,13 @@ +include LICENSE +include README.md +include README_JP.md +include CONTEXT.md +include CHANGELOG.md +recursive-include docs *.md +recursive-include skill *.md +recursive-include demo *.py *.md *.js *.cjs package.json package-lock.json +recursive-include tests *.py *.ts *.toml +recursive-include tools/render package.json package-lock.json +recursive-include .github/workflows *.yml +prune demo/jest_runtime/node_modules +prune tools/render/node_modules diff --git a/README.md b/README.md index f981651..6064aa0 100644 --- a/README.md +++ b/README.md @@ -1,273 +1,274 @@ # TeaForge -TeaForge converts automated tests into Japanese-style unit test documentation (PCL, Program Check List) and generates coverage reports with Mermaid flowcharts. +[![CI](https://github.com/cyberyimein/TeaForge/actions/workflows/ci.yml/badge.svg)](https://github.com/cyberyimein/TeaForge/actions/workflows/ci.yml) -This project is not just a command-line tool for manual human use. Its primary use case is collaboration with AI agents. The `skill/SKILL.md` file provides agents with TeaForge knowledge such as capabilities, CLI interfaces, usage boundaries, and flowchart generation rules. Agents such as GitHub Copilot and Claude Code can load that skill to learn when TeaForge should be called, which arguments should be passed, and how to correct a failed invocation. That makes test generation, documentation generation, and coverage reporting more reliable. +TeaForge turns automated tests into auditable Japanese-style unit-test specifications (PCL, Program Check List) and file-level coverage reports with Mermaid flowcharts and sequence diagrams. -Currently implemented: +It is designed for both engineers and coding agents. The bundled [`skill/SKILL.md`](skill/SKILL.md) describes the supported workflows, command boundaries, and diagram rules so an agent can invoke TeaForge consistently. -- `pytest` test parsing -- `jest` / `TypeScript` test parsing -- PCL generation as `JSON + HTML` -- HTML export to PDF (`WeasyPrint`) -- CLI lookup for individual testcase descriptions -- File-level multi-page coverage reports (C0 / C1 + Mermaid SVG) +## Capabilities -## Directory Structure - -```text -TeaForge/ - src/teaforge/ # Core implementation and CLI - templates/ # PCL HTML templates - demo/fastapi_crud/ # Internal FastAPI + SQLite + pytest demo - tests/fixtures/jest_sample/ # Minimal Jest / TypeScript fixture - output/ # Local HTML / JSON / PDF output directory - tests/ # TeaForge's own test suite - skill/ # Skill file - project.md # Project goal description - Step1.md # Phase 1 requirements -``` +- Parse `pytest`, Jest/TypeScript, Angular/Jest, and Playwright tests into PCL documents. +- Capture observed Jest matcher evidence: expected value, actual value, matcher, pass/fail, `.not`, Promise, and thrown-error behavior. +- Generate versioned PCL artifacts as sibling JSON and HTML files. +- Measure file-level C0/C1 coverage from Python coverage data or Jest/Istanbul `coverage-final.json`. +- Add typed Mermaid flowchart and sequence-diagram pages to coverage reports. +- Export generated HTML to PDF with the optional WeasyPrint dependency. +- Inspect the installed package and target project with a machine-readable `doctor` command. ## Installation -### macOS / Linux +TeaForge requires Python 3.11 or newer. + +### Use from a checkout + +macOS / Linux: ```bash +git clone https://github.com/cyberyimein/TeaForge.git +cd TeaForge python3 -m venv .venv source .venv/bin/activate -pip install -e ".[dev,pdf]" +python -m pip install . ``` -### Windows +Windows PowerShell: ```powershell +git clone https://github.com/cyberyimein/TeaForge.git +cd TeaForge py -m venv .venv .venv\Scripts\Activate.ps1 -pip install -e ".[dev,pdf]" -``` - -## Node / Jest Prerequisites - -If you want to use the `jest` / `TypeScript` PCL or coverage features, you also need a local Node.js environment. - -### Required Tools - -- `node` -- `npx` -- A project-local `jest` executable - -### Coverage Requirements - -`teaforge coverage generate --framework jest` depends on Jest producing Istanbul `coverage-final.json`. - -TeaForge currently invokes: - -```bash -npx jest --coverage --coverageReporters=json --coverageDirectory --runInBand -``` - -### Mermaid Requirements - -Whether you generate coverage reports from `pytest` or `jest`, Mermaid flowcharts still require a local `mmdc` installation: - -```bash -npm install -g @mermaid-js/mermaid-cli +python -m pip install . ``` -## PDF Export Dependencies (WeasyPrint) - -TeaForge HTML generation only depends on Python packages. PDF export additionally depends on WeasyPrint system libraries. -If `teaforge export` reports missing `libgobject`, `Pango`, `Cairo`, or missing DLL / shared libraries, install the required packages for your platform. - -### macOS - -According to the official WeasyPrint documentation, the simplest approach is to install WeasyPrint and its dependencies with Homebrew first: +Install PDF support only when it is needed: ```bash -brew install weasyprint +python -m pip install ".[pdf]" ``` -If you still run TeaForge inside the project's virtual environment, keep this step as well: +For development and the repository demos: ```bash -source .venv/bin/activate -pip install -e ".[dev,pdf]" +python -m pip install -e ".[dev,pdf]" ``` -If shared libraries are still missing, you can set: +Confirm the installed CLI and packaged resources: ```bash -export DYLD_FALLBACK_LIBRARY_PATH="/opt/homebrew/lib:$DYLD_FALLBACK_LIBRARY_PATH" +teaforge --version +teaforge doctor --framework pytest --path demo/fastapi_crud/tests ``` -The default prefix is usually `/opt/homebrew` on Apple Silicon and `/usr/local` on Intel Macs. +## External tools -### Windows +### Jest -TeaForge may also run on Windows. For PDF export you need to install Pango and its dependencies in advance. -Based on the official WeasyPrint documentation, the recommended flow is: +Jest runtime evidence and Jest coverage require `node` and an already-installed project-local Jest. TeaForge discovers the nearest `node_modules/.bin/jest`, including a Jest hoisted above a workspace package, runs it from the target project root, and passes exact test paths. -1. Install Python. -2. Install [MSYS2](https://www.msys2.org/). -3. Run this in the MSYS2 shell: +TeaForge never invokes `npx` and never downloads Jest implicitly. Install the target project's locked dependencies first, for example: ```bash -pacman -S mingw-w64-x86_64-pango +npm ci +teaforge doctor --framework jest --path tests/user.test.js --json ``` -4. Go back to PowerShell or `cmd` and install the project: +### Mermaid -```powershell -py -m venv .venv -.venv\Scripts\Activate.ps1 -pip install -e ".[dev,pdf]" -``` +Syntax validation and SVG rendering require `mmdc`: -If DLLs are still missing, set this in the current terminal: - -```powershell -$env:WEASYPRINT_DLL_DIRECTORIES="C:\msys64\mingw64\bin" +```bash +npm install -g @mermaid-js/mermaid-cli +teaforge doctor --framework pytest --require-mermaid ``` -The equivalent in `cmd.exe` is: +Environments that require explicit Chromium launch settings may point `TEAFORGE_MERMAID_PUPPETEER_CONFIG` to an `mmdc` Puppeteer JSON file. The repository uses `--no-sandbox` only inside its isolated CI runner; do not copy that setting into a general-purpose workstation. -```cmd -set WEASYPRINT_DLL_DIRECTORIES=C:\msys64\mingw64\bin -``` +### PDF -### Export Command Example +PDF export uses the optional `weasyprint` package and its native Pango/Cairo dependencies. Run this check before depending on PDF output: ```bash -teaforge export --path output/test_items/demo-pcl-test-create-item-normal.html --output output/test_items/demo-pcl-test-create-item-normal.pdf +teaforge doctor --framework pytest --require-pdf ``` -## CLI Usage - -TeaForge's primary CLI user is an agent, not a human terminal user. That means the design goal is not “what is the shortest command a human can type”, but rather: - -- Can the agent understand parameters and constraints from `help` output? -- Can the agent get a clear correction path from failure messages? -- Can the agent chain together the PCL / coverage / Mermaid workflows automatically? +On macOS, `brew install weasyprint` is the simplest way to install native dependencies. On Windows, follow the WeasyPrint/MSYS2 instructions and make the Pango DLL directory available through `WEASYPRINT_DLL_DIRECTORIES`. -For that reason, TeaForge CLI tries to provide readable help, explicit error messages, and actionable correction hints when dependencies or inputs are missing. That feedback is meant to be consumed by agents so they can retry with corrected parameters. +## Quick start -TeaForge currently selects the test framework through `--framework`: - -- `pytest`: default -- `jest`: for Node.js / TypeScript Jest tests - -Generate PCL: +### Generate a pytest PCL ```bash -teaforge pcl generate --path demo/fastapi_crud/tests --output output/pcl.html +teaforge pcl generate \ + --path demo/fastapi_crud/tests \ + --output output/pcl.html ``` -Notes: - -- By default, TeaForge generates one PCL file per tested function. Multiple testcases targeting the same function are merged into the same PCL. -- In the generated PCL, `file` and `method` prefer the actual tested source file and implementation function. In the demo this becomes `main.py` / `create_item`. -- The matrix always reserves 25 testcase columns. -- If one function has more than 25 testcases, TeaForge automatically splits them into multiple sheet files. -- If the input path contains multiple tested functions, TeaForge creates subdirectories by tested file name and writes each function's HTML/JSON there. +TeaForge groups test cases by proven test subject. It emits one HTML/JSON pair per subject, reserves 25 matrix columns per sheet, and splits larger groups into numbered sheets. Every JSON artifact contains a `schema_version` field. -Generate Jest / TypeScript PCL: +### Generate a runtime-backed Jest PCL ```bash teaforge pcl generate \ --framework jest \ - --path tests/fixtures/test_sample_jest.test.ts \ + --evidence-mode runtime \ + --path demo/jest_runtime/tests/user.test.js \ --output output/jest-pcl.html ``` -Main Jest syntax currently supported: +Evidence modes: -- `test(...)` -- `it(...)` -- `test.only(...)` / `it.only(...)` -- `test.each([...])(...)` -- Relative-path `import` -- Direct imported function calls -- `expect(...).toBe(...)` -- `expect(...).toEqual(...)` -- `expect(...).toStrictEqual(...)` -- `expect(...).toContain(...)` -- `expect(() => fn(...)).toThrow(...)` +- `static`: parse design-time expectations without executing Jest. +- `runtime`: require Jest and record observed matcher evidence. +- `auto`: try runtime evidence and fall back only when the project-local runner or assertion hook is unavailable. Timeouts and Jest configuration/execution errors remain failures instead of being hidden by a static report. -Current Jest limitations: +If Jest executes and tests fail, TeaForge still writes the evidence-bearing report and exits with code `2`. Use `--allow-test-failures` only when that result is intentionally acceptable. `--runtime-timeout` bounds the whole execution; on expiry TeaForge terminates the runner process tree and preserves only bounded diagnostics. -- Relative `TypeScript` / `JavaScript` imports are the primary supported path. -- `test.each` currently supports array-literal row data. -- Not all Jest / ts-jest / Babel syntax variants are covered yet. -- There is not yet a full Node.js demo project. Validation currently relies mainly on `tests/fixtures/jest_sample`. +The structural parser uses TeaForge-packaged Tree-sitter JavaScript, TypeScript, and TSX grammars. It supports common `test`/`it` forms, nested `describe`, array-literal `test.each`/`describe.each`, ESM relative imports, CommonJS destructured/aliased/namespace `require`, direct calls, and common matchers. It never installs anything into the target project. Dynamic imports, computed test construction, and runtime-built values may still require runtime evidence. -Export PDF: +### Query and export a PCL ```bash +teaforge get --path output/pcl.json --testcase TC-001 teaforge export --path output/pcl.html --output output/pcl.pdf ``` -Query a testcase: +### Prepare diagrams -```bash -teaforge get --path output/pcl.html --testcase TC-001 -``` - -Notes: - -- `generate` also writes a sibling `.json` file for each HTML file. -- `get` prefers JSON. If you pass HTML with embedded data, it can read that directly as well. -- Agents can read command descriptions with `teaforge help`, `teaforge help pcl generate`, and `teaforge help coverage generate`, then adjust parameters based on error feedback. - -Validate Mermaid: +Flowchart: ```bash -teaforge mermaid validate --code "flowchart TD - A[Start] --> B[End]" +teaforge mermaid generate \ + --source demo/fastapi_crud/app/main.py \ + --function create_item \ + --diagram-type flowchart \ + --code "flowchart TD + A[Receive request] --> B{Valid?} + B -- No --> C[Return validation error] + B -- Yes --> D[Create item] + D --> E[Return item]" \ + --output-dir output/files ``` -Generate a function-level Mermaid file: +Sequence diagram: ```bash teaforge mermaid generate \ --source demo/fastapi_crud/app/main.py \ --function create_item \ - --code "flowchart TD - A[Start] --> B[Create item] - B --> C[Return response]" \ + --diagram-type sequence \ + --code "sequenceDiagram + Client->>API: Create item + API->>DB: Insert item + DB-->>API: Stored row + API-->>Client: Created response" \ --output-dir output/files ``` -Generate a coverage report: +`mermaid generate` validates and stores the typed `.mmd` source. Unsupported diagram types fail instead of being mislabeled. + +### Generate coverage reports + +Python: ```bash teaforge coverage generate \ --path demo/fastapi_crud/tests \ - --output output/main_coverage_report.html \ + --output output/main-coverage.html \ + --min-c0 90 \ + --min-c1 100 \ --function create_item \ - --function update_item \ + --sequence create_item \ --diagram-dir output/files ``` -Generate a Jest / TypeScript coverage report: +When the tested project uses a different virtual environment, run coverage with that environment's interpreter: + +```bash +teaforge doctor \ + --framework pytest \ + --path /project/tests \ + --python-executable /project/.venv/bin/python + +teaforge coverage generate \ + --path /project/tests \ + --python-executable /project/.venv/bin/python \ + --runtime-timeout 180 \ + --output output/project-coverage.html +``` + +Jest: ```bash teaforge coverage generate \ --framework jest \ - --path tests/fixtures/test_sample_jest.test.ts \ - --output output/jest_coverage_report.html \ - --function createUser \ - --diagram-dir output/files + --path demo/jest_runtime/tests/user.test.js \ + --output output/jest-coverage.html \ + --runtime-timeout 180 +``` + +Coverage reports are grouped by proven business source file. TeaForge fails fast when it cannot map a Jest test to a real source file. `--function` adds a flowchart page and `--sequence` adds a sequence page; both options are repeatable and may target the same function. A metric with no measurable statements or branches is shown as `N/A` and is excluded from that metric's threshold gate. + +## CLI help and exit behavior + +```bash +teaforge help +teaforge help pcl generate +teaforge help coverage generate +``` + +- Exit `0`: command completed and its required result is acceptable. +- Exit `1`: invalid input, missing capability, execution failure, or generation failure. +- Exit `2`: Jest ran, reports were written, but at least one test failed. +- Exit `3`: coverage reports were written, but `--min-c0` or `--min-c1` was not met. + +Writes use same-directory temporary files and atomic replacement so an interrupted single-file write does not destroy the previous artifact. Multi-file generation validates and renders all documents before replacement, but concurrent writers to the same output path are not supported. + +## Project layout + +```text +TeaForge/ + src/teaforge/ # CLI and service modules + src/teaforge/templates/ # Packaged PCL and coverage templates + src/teaforge/jest/assets # Packaged runtime listener + demo/fastapi_crud/ # Executable pytest demo + demo/jest_runtime/ # Locked executable Jest demo + tests/ # Unit and CLI integration tests + docs/adr/ # Durable architecture decisions + CHANGELOG.md # Release-level behavior changes + skill/SKILL.md # Agent-facing workflow instructions + .github/workflows/ci.yml # Tests, package checks, and real Jest smoke tests ``` -Notes: - -- Coverage reports are generated per tested source file. The summary page includes all functions, but only functions specified with `--function` get dedicated flowchart pages. -- You do not need flowcharts for simple helper functions such as `_db_path`. Prefer real business functions with conditions or error paths. -- Requested business functions must already have Mermaid `.mmd` files prepared. If they are missing, the CLI tells you to call `teaforge mermaid generate` first. -- Mermaid content should show `if/else`, validation failure, exception return paths, and major business branches. Do not generate meaningless “start -> call function -> end” diagrams. -- `teaforge mermaid validate` and `teaforge mermaid generate` use local `mmdc` for real syntax validation. If it is missing, TeaForge prints the installation hint directly. -- When generating reports, TeaForge renders Mermaid to SVG and embeds it as a self-contained image in HTML. This avoids editor-side confusion where raw Mermaid SVG styles may be mistaken for page CSS errors. -- Mermaid-to-SVG conversion depends on local `mmdc`, which you can install with `npm install -g @mermaid-js/mermaid-cli`. -- `jest` coverage currently reads Istanbul `coverage-final.json` and requires the test file to be mapped to a real business source file. If TeaForge cannot prove the source, it fails fast. -- `jest` source function parsing currently covers top-level functions, arrow functions, and class methods. -- When a command fails, TeaForge tries to return actionable correction hints, such as missing `mmdc`, missing Mermaid files, unresolved source files, or an invalid framework parameter. Those messages are expected to be read by an agent and used to correct the next invocation. +The shared domain language is documented in [`CONTEXT.md`](CONTEXT.md). Execution and artifact decisions are recorded in [`docs/adr/`](docs/adr/). + +## Development and release checks + +```bash +python -m venv .venv +source .venv/bin/activate +python -m pip install -e '.[dev]' +python -m ruff check src tests demo +python -m coverage run -m pytest -q +python -m coverage report +python -m compileall -q src tests +npm --prefix demo/jest_runtime ci +npm --prefix demo/jest_runtime test +python -m build +python -m twine check dist/* +``` + +Recreate `.venv` after copying the repository to another computer; virtual environments contain machine-specific interpreter paths and should not be moved with the source tree. + +CI runs Python 3.11 and 3.14 on Linux plus Python 3.12 on Windows, enforces Ruff and an 80% branch-coverage baseline, builds wheel/sdist packages, runs real pytest coverage from an isolated wheel install, and executes the locked Jest demo on Node 20 and 22 from outside the project directory. + +## Known boundaries + +- JavaScript/TypeScript source discovery is structural and ignores import/test-shaped text in comments and strings. It is not a TypeScript type checker and does not evaluate dynamic or heavily transformed suites; those suites should use runtime evidence and verify the resolved source identity. +- Directory discovery prunes generated dependency and environment trees such as `node_modules`, `.venv`, `dist`, and `build`; pass an exact file path when one of those trees is intentionally the test subject. +- Runtime assertion evidence applies default sensitive-key and common credential-pattern redaction, value truncation, and bounded record/file limits. Projects with stricter confidentiality requirements must still review whether that default policy is sufficient before retaining reports. +- C0/C1 values retain their evidence provider and metric definitions in the artifact. Optional `--min-c0` and `--min-c1` gates apply to every generated source-file report. +- Mermaid and PDF are explicit optional capabilities and are checked independently by `teaforge doctor`. + +These boundaries are kept explicit so automation can fail safely instead of silently producing a plausible but incorrect report. diff --git a/README_JP.md b/README_JP.md index 40f99c9..95b5d9c 100644 --- a/README_JP.md +++ b/README_JP.md @@ -1,273 +1,272 @@ # TeaForge -TeaForge は、自動テストを日本式の単体テストドキュメント(PCL, Program Check List)へ変換し、Mermaid フローチャート付きのカバレッジレポートを生成します。 +[![CI](https://github.com/cyberyimein/TeaForge/actions/workflows/ci.yml/badge.svg)](https://github.com/cyberyimein/TeaForge/actions/workflows/ci.yml) -このプロジェクトは、単に人間が手動で使うためのコマンドラインツールではありません。主な利用シナリオは AI エージェントとの協調です。`skill/SKILL.md` には、TeaForge の機能、CLI インターフェース、利用境界、フローチャート生成ルールなどの知識がまとめられています。GitHub Copilot や Claude Code のようなエージェントは、この skill を読み込むことで、TeaForge をいつ呼び出すべきか、どの引数を渡すべきか、失敗した呼び出しをどう修正すべきかを理解できます。これにより、テスト生成、ドキュメント生成、カバレッジレポート生成の信頼性が向上します。 +TeaForge は、自動テストから監査可能な日本式単体テスト仕様書(PCL, Program Check List)と、Mermaid のフローチャート/シーケンス図を含むファイル単位カバレッジレポートを生成します。 -現在実装済みの機能: +エンジニアとコーディングエージェントの両方を対象にしています。付属の [`skill/SKILL.md`](skill/SKILL.md) には、対応ワークフロー、コマンド境界、作図ルールを記載しています。 -- `pytest` テスト解析 -- `jest` / `TypeScript` テスト解析 -- `JSON + HTML` での PCL 生成 -- HTML の PDF 出力(WeasyPrint) -- 個別テストケース説明の CLI 参照 -- ファイル単位の複数ページ対応カバレッジレポート(C0 / C1 + Mermaid SVG) +## 主な機能 -## ディレクトリ構成 - -```text -TeaForge/ - src/teaforge/ # コア実装と CLI - templates/ # PCL HTML テンプレート - demo/fastapi_crud/ # 内部用 FastAPI + SQLite + pytest デモ - tests/fixtures/jest_sample/ # 最小構成の Jest / TypeScript フィクスチャ - output/ # ローカル HTML / JSON / PDF 出力先 - tests/ # TeaForge 自身のテストスイート - skill/ # Skill ファイル - project.md # プロジェクト目標の説明 - Step1.md # フェーズ 1 要件 -``` +- `pytest`、Jest/TypeScript、Angular/Jest、Playwright のテストを PCL に変換 +- Jest の実行時 matcher 証拠(期待値、実際値、matcher、成否、`.not`、Promise、例外)を取得 +- バージョン付き PCL を JSON と HTML の組として生成 +- Python coverage または Jest/Istanbul `coverage-final.json` からファイル単位 C0/C1 レポートを生成 +- カバレッジレポートに型付き Mermaid フローチャート/シーケンス図ページを追加 +- 任意依存の WeasyPrint による PDF 出力 +- `doctor` によるパッケージと対象プロジェクトの機械可読な事前診断 ## インストール -### macOS / Linux +Python 3.11 以降が必要です。 + +macOS / Linux: ```bash +git clone https://github.com/cyberyimein/TeaForge.git +cd TeaForge python3 -m venv .venv source .venv/bin/activate -pip install -e ".[dev,pdf]" +python -m pip install . ``` -### Windows +Windows PowerShell: ```powershell +git clone https://github.com/cyberyimein/TeaForge.git +cd TeaForge py -m venv .venv .venv\Scripts\Activate.ps1 -pip install -e ".[dev,pdf]" -``` - -## Node / Jest の前提条件 - -`jest` / `TypeScript` の PCL またはカバレッジ機能を使う場合は、ローカルの Node.js 環境も必要です。 - -### 必要ツール - -- `node` -- `npx` -- プロジェクトローカルの `jest` 実行ファイル - -### カバレッジ要件 - -`teaforge coverage generate --framework jest` は、Jest が生成する Istanbul 形式の `coverage-final.json` に依存します。 - -TeaForge は現在、次のコマンドを呼び出します: - -```bash -npx jest --coverage --coverageReporters=json --coverageDirectory --runInBand -``` - -### Mermaid 要件 - -カバレッジレポートを `pytest` から生成する場合でも `jest` から生成する場合でも、Mermaid フローチャートにはローカルの `mmdc` インストールが必要です: - -```bash -npm install -g @mermaid-js/mermaid-cli +python -m pip install . ``` -## PDF 出力依存関係(WeasyPrint) - -TeaForge の HTML 生成は Python パッケージのみに依存します。PDF 出力はそれに加えて WeasyPrint のシステムライブラリが必要です。 -`teaforge export` 実行時に `libgobject`、`Pango`、`Cairo`、または DLL / 共有ライブラリ不足が報告された場合は、利用しているプラットフォーム向けの必要パッケージをインストールしてください。 - -### macOS - -WeasyPrint 公式ドキュメントによると、最も簡単な方法はまず Homebrew で WeasyPrint と依存関係をインストールすることです: +PDF が必要な場合だけ追加依存を入れます。 ```bash -brew install weasyprint +python -m pip install ".[pdf]" ``` -そのうえで、プロジェクトの仮想環境内で TeaForge を使う場合は、次の手順も実行してください: +開発とリポジトリ内デモには次を使用します。 ```bash -source .venv/bin/activate -pip install -e ".[dev,pdf]" +python -m pip install -e ".[dev,pdf]" ``` -共有ライブラリがまだ見つからない場合は、次を設定できます: +インストール後に CLI と同梱リソースを確認します。 ```bash -export DYLD_FALLBACK_LIBRARY_PATH="/opt/homebrew/lib:$DYLD_FALLBACK_LIBRARY_PATH" +teaforge --version +teaforge doctor --framework pytest --path demo/fastapi_crud/tests ``` -通常のプレフィックスは、Apple Silicon Mac では `/opt/homebrew`、Intel Mac では `/usr/local` です。 +## 外部ツール -### Windows +### Jest -TeaForge は Windows でも動作する可能性があります。PDF 出力には、事前に Pango とその依存関係をインストールする必要があります。 -WeasyPrint 公式ドキュメントに基づく推奨手順は次のとおりです: +Jest の実行時証拠とカバレッジには `node` と、対象プロジェクトへ導入済みの Jest が必要です。TeaForge は最寄りの `node_modules/.bin/jest`(ワークスペース上位へ hoist されたものを含む)を発見し、対象プロジェクトのルートから正確なテストパスを指定して実行します。 -1. Python をインストールします。 -2. [MSYS2](https://www.msys2.org/) をインストールします。 -3. MSYS2 シェルで次を実行します: +TeaForge は `npx` を呼ばず、Jest を暗黙にダウンロードしません。先に対象プロジェクトの lockfile に従って依存を導入してください。 ```bash -pacman -S mingw-w64-x86_64-pango +npm ci +teaforge doctor --framework jest --path tests/user.test.js --json ``` -4. PowerShell または `cmd` に戻って、プロジェクトをインストールします: +### Mermaid -```powershell -py -m venv .venv -.venv\Scripts\Activate.ps1 -pip install -e ".[dev,pdf]" -``` - -DLL がまだ不足する場合は、現在のターミナルで次を設定します: +構文検証と SVG 描画には `mmdc` が必要です。 -```powershell -$env:WEASYPRINT_DLL_DIRECTORIES="C:\msys64\mingw64\bin" +```bash +npm install -g @mermaid-js/mermaid-cli +teaforge doctor --framework pytest --require-mermaid ``` -`cmd.exe` での等価な設定は次のとおりです: +Chromium の起動設定が必要な環境では、`TEAFORGE_MERMAID_PUPPETEER_CONFIG` に `mmdc` 用 Puppeteer JSON ファイルを指定できます。このリポジトリの `--no-sandbox` は隔離された CI runner だけで使用します。一般的な開発端末へその設定をコピーしないでください。 -```cmd -set WEASYPRINT_DLL_DIRECTORIES=C:\msys64\mingw64\bin -``` +### PDF -### 出力コマンド例 +PDF 出力は任意依存の `weasyprint` と Pango/Cairo のネイティブ依存を使用します。利用前に確認してください。 ```bash -teaforge export --path output/test_items/demo-pcl-test-create-item-normal.html --output output/test_items/demo-pcl-test-create-item-normal.pdf +teaforge doctor --framework pytest --require-pdf ``` -## CLI の使い方 - -TeaForge の主要な CLI 利用者は、人間のターミナルユーザーではなくエージェントです。つまり、設計目標は「人間が最短で打てるコマンドは何か」ではなく、次の点にあります: - -- エージェントが `help` 出力からパラメータと制約を理解できるか -- 失敗メッセージから明確な修正経路を取得できるか -- PCL / カバレッジ / Mermaid のワークフローを自動的につなげられるか - -このため、TeaForge CLI は読みやすいヘルプ、明示的なエラーメッセージ、依存関係や入力不足時の実行可能な修正ヒントを提供することを重視しています。これらのフィードバックはエージェントが読み取り、修正したパラメータで再実行することを想定しています。 +macOS では `brew install weasyprint` が簡単です。Windows では WeasyPrint/MSYS2 の手順に従い、必要に応じて `WEASYPRINT_DLL_DIRECTORIES` で Pango DLL の場所を指定してください。 -TeaForge は現在、`--framework` でテストフレームワークを選択します: +## クイックスタート -- `pytest`: デフォルト -- `jest`: Node.js / TypeScript の Jest テスト向け - -PCL を生成する: +### pytest PCL ```bash -teaforge pcl generate --path demo/fastapi_crud/tests --output output/pcl.html +teaforge pcl generate \ + --path demo/fastapi_crud/tests \ + --output output/pcl.html ``` -補足: +TeaForge は証明できたテスト対象ごとにケースをまとめ、HTML/JSON の組を出力します。1 シートは 25 ケース列で、超過分は複数シートに分割されます。JSON には `schema_version` が含まれます。 -- デフォルトでは、TeaForge はテスト対象関数ごとに 1 つの PCL ファイルを生成します。同じ関数を対象にする複数テストケースは同じ PCL に統合されます。 -- 生成された PCL では、`file` と `method` は実際のテスト対象ソースファイルと実装関数を優先します。デモでは `main.py` / `create_item` になります。 -- マトリクスは常に 25 個のテストケース列を確保します。 -- 1 つの関数に 25 件を超えるテストケースがある場合、TeaForge は自動的に複数のシートファイルへ分割します。 -- 入力パスに複数のテスト対象関数が含まれる場合、TeaForge はテスト対象ファイル名ごとにサブディレクトリを作成し、それぞれの HTML/JSON を出力します。 - -Jest / TypeScript PCL を生成する: +### 実行時証拠付き Jest PCL ```bash teaforge pcl generate \ --framework jest \ - --path tests/fixtures/test_sample_jest.test.ts \ + --evidence-mode runtime \ + --path demo/jest_runtime/tests/user.test.js \ --output output/jest-pcl.html ``` -現在主にサポートしている Jest 構文: +証拠モード: -- `test(...)` -- `it(...)` -- `test.only(...)` / `it.only(...)` -- `test.each([...])(...)` -- 相対パス `import` -- 直接 import された関数呼び出し -- `expect(...).toBe(...)` -- `expect(...).toEqual(...)` -- `expect(...).toStrictEqual(...)` -- `expect(...).toContain(...)` -- `expect(() => fn(...)).toThrow(...)` +- `static`: Jest を実行せず、設計時の期待値を静的解析 +- `runtime`: Jest 実行を必須とし、観測した matcher 証拠を記録 +- `auto`: 実行時証拠を試し、プロジェクトローカル runner または assertion hook が利用不能な場合だけ静的解析へフォールバック。タイムアウトや Jest 設定/実行エラーは静的レポートで隠さず失敗として扱います。 -現在の Jest 制限事項: +Jest が実行されテストが失敗した場合も、TeaForge は失敗証拠を含むレポートを書き、終了コード `2` を返します。失敗を意図的に受け入れる場合だけ `--allow-test-failures` を使ってください。`--runtime-timeout` はワークフロー全体の上限で、期限切れ時はランナーのプロセスツリーを終了し、診断出力も上限付きで保持します。 -- 相対 `TypeScript` / `JavaScript` import が主なサポート対象です。 -- `test.each` は現在、配列リテラルの行データをサポートしています。 -- すべての Jest / ts-jest / Babel 構文バリエーションをまだ網羅していません。 -- 完全な Node.js デモプロジェクトはまだありません。現状の検証は主に `tests/fixtures/jest_sample` に依存しています。 +静的パーサーは一般的な `test`/`it`、配列リテラルの `test.each`、ESM 相対 import、CommonJS の分割代入/別名/名前空間 `require`、直接関数呼び出し、主要 matcher に対応します。複雑な変換構文、動的 import、計算で生成されるテストは実行時証拠が必要になる場合があります。 -PDF を出力する: +### PCL の参照と PDF 出力 ```bash +teaforge get --path output/pcl.json --testcase TC-001 teaforge export --path output/pcl.html --output output/pcl.pdf ``` -テストケースを参照する: +### 図を用意する -```bash -teaforge get --path output/pcl.html --testcase TC-001 -``` - -補足: - -- `generate` は各 HTML ファイルに対応する `.json` ファイルも同時に出力します。 -- `get` は JSON を優先して読み込みます。埋め込みデータを含む HTML を渡した場合も、そこから直接読み取れます。 -- エージェントは `teaforge help`、`teaforge help pcl generate`、`teaforge help coverage generate` の説明を読み、エラーフィードバックに応じてパラメータを調整できます。 - -Mermaid を検証する: +フローチャート: ```bash -teaforge mermaid validate --code "flowchart TD - A[Start] --> B[End]" +teaforge mermaid generate \ + --source demo/fastapi_crud/app/main.py \ + --function create_item \ + --diagram-type flowchart \ + --code "flowchart TD + A[Receive request] --> B{Valid?} + B -- No --> C[Return validation error] + B -- Yes --> D[Create item] + D --> E[Return item]" \ + --output-dir output/files ``` -関数単位の Mermaid ファイルを生成する: +シーケンス図: ```bash teaforge mermaid generate \ --source demo/fastapi_crud/app/main.py \ --function create_item \ - --code "flowchart TD - A[Start] --> B[Create item] - B --> C[Return response]" \ + --diagram-type sequence \ + --code "sequenceDiagram + Client->>API: Create item + API->>DB: Insert item + DB-->>API: Stored row + API-->>Client: Created response" \ --output-dir output/files ``` -カバレッジレポートを生成する: +`mermaid generate` は型を検証した `.mmd` ソースを保存します。未対応の図種は誤った型として保存せず、明示的に失敗します。 + +### カバレッジレポート + +Python: ```bash teaforge coverage generate \ --path demo/fastapi_crud/tests \ - --output output/main_coverage_report.html \ + --output output/main-coverage.html \ + --min-c0 90 \ + --min-c1 100 \ --function create_item \ - --function update_item \ + --sequence create_item \ --diagram-dir output/files ``` -Jest / TypeScript カバレッジレポートを生成する: +対象プロジェクトが別の仮想環境を使う場合は、その Python を指定します。 + +```bash +teaforge doctor \ + --framework pytest \ + --path /project/tests \ + --python-executable /project/.venv/bin/python + +teaforge coverage generate \ + --path /project/tests \ + --python-executable /project/.venv/bin/python \ + --runtime-timeout 180 \ + --output output/project-coverage.html +``` + +Jest: ```bash teaforge coverage generate \ --framework jest \ - --path tests/fixtures/test_sample_jest.test.ts \ - --output output/jest_coverage_report.html \ - --function createUser \ - --diagram-dir output/files + --path demo/jest_runtime/tests/user.test.js \ + --output output/jest-coverage.html \ + --runtime-timeout 180 ``` -補足: - -- カバレッジレポートはテスト対象ソースファイル単位で生成されます。サマリーページにはすべての関数が含まれますが、`--function` で指定した関数だけが専用フローチャートページを持ちます。 -- `_db_path` のような単純なヘルパー関数にはフローチャートは不要です。条件分岐やエラーパスを持つ実際の業務関数を優先してください。 -- 要求された業務関数については、対応する Mermaid の `.mmd` ファイルが事前に用意されている必要があります。存在しない場合、CLI は先に `teaforge mermaid generate` を呼ぶよう案内します。 -- Mermaid の内容には、`if/else`、バリデーション失敗、例外返却パス、主要な業務分岐を含めるべきです。意味のない「start -> call function -> end」のような図は生成しないでください。 -- `teaforge mermaid validate` と `teaforge mermaid generate` は、実際の構文検証のためにローカルの `mmdc` を使用します。未導入の場合、TeaForge はインストールヒントを直接出力します。 -- レポート生成時、TeaForge は Mermaid を SVG にレンダリングし、自己完結型の画像として HTML に埋め込みます。これにより、生の Mermaid SVG スタイルがページ CSS エラーと誤認される問題を避けます。 -- Mermaid から SVG への変換はローカルの `mmdc` に依存し、`npm install -g @mermaid-js/mermaid-cli` でインストールできます。 -- `jest` カバレッジは現在、Istanbul 形式の `coverage-final.json` を読み込み、テストファイルが実際の業務ソースファイルへ対応付けられていることを必要とします。TeaForge がソースを証明できない場合は即時に失敗します。 -- `jest` のソース関数解析は現在、トップレベル関数、アロー関数、クラスメソッドを対象にしています。 -- コマンドが失敗した場合、TeaForge は `mmdc` 不足、Mermaid ファイル不足、ソースファイル未解決、不正な framework パラメータなど、実行可能な修正ヒントを返すようにしています。これらのメッセージはエージェントが読み取り、次の呼び出しを修正することを想定しています。 \ No newline at end of file +レポートは証明できた業務ソースファイル単位で生成されます。Jest テストを実ソースへ対応付けられない場合は即時に失敗します。`--function` はフローチャート、`--sequence` はシーケンス図を追加し、どちらも繰り返し指定できます。測定対象の文または分岐が存在しない指標は `N/A` と表示し、その指標の閾値判定から除外します。 + +## CLI ヘルプと終了コード + +```bash +teaforge help +teaforge help pcl generate +teaforge help coverage generate +``` + +- `0`: コマンドが完了し、必須結果も許容可能 +- `1`: 入力不正、能力不足、実行失敗、生成失敗 +- `2`: Jest は実行されレポートも生成されたが、1 件以上のテストが失敗 +- `3`: カバレッジレポートは生成されたが、`--min-c0` または `--min-c1` を未達 + +出力は同じディレクトリの一時ファイルへ書き、原子的に置換します。これにより単一ファイル書き込み中断時に以前の成果物を壊しません。複数成果物はすべての検証・描画後に置換しますが、同じ出力パスへの並行書き込みは未対応です。 + +## プロジェクト構成 + +```text +TeaForge/ + src/teaforge/ # CLI とサービスモジュール + src/teaforge/templates/ # 同梱 PCL/カバレッジテンプレート + src/teaforge/jest/assets # 同梱実行時 listener + demo/fastapi_crud/ # 実行可能 pytest デモ + demo/jest_runtime/ # lockfile 付き Jest デモ + tests/ # 単体・CLI 統合テスト + docs/adr/ # 長期的なアーキテクチャ判断 + CHANGELOG.md # リリース単位の変更履歴 + skill/SKILL.md # エージェント向け手順 + .github/workflows/ci.yml # テスト、パッケージ、実 Jest 検証 +``` + +共通のドメイン用語は [`CONTEXT.md`](CONTEXT.md)、実行/成果物の設計判断は [`docs/adr/`](docs/adr/) に記録しています。 + +## 開発・リリース確認 + +```bash +python -m venv .venv +source .venv/bin/activate +python -m pip install -e '.[dev]' +python -m ruff check src tests demo +python -m coverage run -m pytest -q +python -m coverage report +python -m compileall -q src tests +npm --prefix demo/jest_runtime ci +npm --prefix demo/jest_runtime test +python -m build +python -m twine check dist/* +``` + +別のコンピュータへリポジトリをコピーした後は `.venv` を作り直してください。仮想環境にはマシン固有の Python パスが含まれるため、ソースと一緒に移動する対象ではありません。 + +CI は Linux 上の Python 3.11/3.14 と Windows 上の Python 3.12 を検証し、Ruff と分岐カバレッジ 80% の基準を適用します。さらに wheel/sdist を構築し、隔離した wheel インストールから実 pytest カバレッジを実行し、Node 20/22 で lockfile 付き Jest デモをプロジェクト外から実行します。 + +## 現在の境界 + +- JavaScript/TypeScript のソース発見は、TeaForge に同梱された Tree-sitter の JavaScript/TypeScript/TSX 文法で構造的に解析し、コメントや文字列中の import/test 風テキストを無視します。ただし TypeScript の型検査や動的・大幅に変換されたテストの評価は行わないため、その場合は実行時証拠を使い、解決されたソース識別子を確認してください。 +- ディレクトリ探索では `node_modules`、`.venv`、`dist`、`build` などの生成済み依存・環境ツリーを除外します。そこを意図的に解析する場合は正確なファイルパスを渡してください。 +- 実行時証拠には、機密キーと一般的な認証情報パターンの既定マスキング、値の切り詰め、レコード/ファイル上限を適用します。より厳しい機密要件を持つプロジェクトでは、レポートを保管する前にこの既定方針で十分か確認してください。 +- C0/C1 の成果物には証拠プロバイダーと指標定義を保持します。任意の `--min-c0`/`--min-c1` ゲートは、生成された全ソースファイルレポートへ適用されます。 +- Mermaid と PDF は明示的な任意能力で、`teaforge doctor` が個別に確認します。 + +これらの境界を明示し、自動処理がもっともらしい誤レポートを黙って生成しないようにしています。 diff --git a/demo/fastapi_crud/app/main.py b/demo/fastapi_crud/app/main.py index a202cc6..a018d0b 100644 --- a/demo/fastapi_crud/app/main.py +++ b/demo/fastapi_crud/app/main.py @@ -2,12 +2,20 @@ import os import sqlite3 +from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, HTTPException from pydantic import BaseModel, Field -app = FastAPI(title="TeaForge Demo CRUD") + +@asynccontextmanager +async def _lifespan(_app: FastAPI): + init_db() + yield + + +app = FastAPI(title="TeaForge Demo CRUD", lifespan=_lifespan) class ItemCreate(BaseModel): @@ -53,11 +61,6 @@ def init_db() -> None: conn.commit() -@app.on_event("startup") -def _on_startup() -> None: - init_db() - - @app.post("/items", response_model=ItemOut) def create_item(payload: ItemCreate) -> ItemOut: try: @@ -126,4 +129,3 @@ def delete_item(item_id: int) -> dict[str, str]: conn.execute("DELETE FROM items WHERE id = ?", (item_id,)) conn.commit() return {"status": "deleted"} - diff --git a/demo/jest_runtime/jest.config.cjs b/demo/jest_runtime/jest.config.cjs new file mode 100644 index 0000000..3796eb0 --- /dev/null +++ b/demo/jest_runtime/jest.config.cjs @@ -0,0 +1,3 @@ +module.exports = { + setupFilesAfterEnv: ["/tests/setup.cjs"], +}; diff --git a/demo/jest_runtime/package-lock.json b/demo/jest_runtime/package-lock.json new file mode 100644 index 0000000..cec7007 --- /dev/null +++ b/demo/jest_runtime/package-lock.json @@ -0,0 +1,4415 @@ +{ + "name": "teaforge-jest-runtime-demo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "teaforge-jest-runtime-demo", + "version": "1.0.0", + "devDependencies": { + "jest": "^30.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.4.1", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.4.1", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.52", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "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/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/babel-jest": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.4.1", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.4.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.4.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "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/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "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/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001805", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", + "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "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/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "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/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "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/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "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/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "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/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "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/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "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/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", + "import-local": "^3.2.0", + "jest-cli": "30.4.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0", + "pretty-format": "30.4.1", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "parse-json": "^5.2.0", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "jest-util": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "picomatch": "^4.0.3", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.4.1", + "graceful-fs": "^4.2.11", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.4.1", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.4.1", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "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/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "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.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/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/synckit": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.3.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "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/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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" + } + } + } +} diff --git a/demo/jest_runtime/package.json b/demo/jest_runtime/package.json new file mode 100644 index 0000000..ebe7299 --- /dev/null +++ b/demo/jest_runtime/package.json @@ -0,0 +1,11 @@ +{ + "name": "teaforge-jest-runtime-demo", + "version": "1.0.0", + "private": true, + "scripts": { + "test": "jest" + }, + "devDependencies": { + "jest": "^30.0.0" + } +} diff --git a/demo/jest_runtime/src/user.js b/demo/jest_runtime/src/user.js new file mode 100644 index 0000000..6b9f629 --- /dev/null +++ b/demo/jest_runtime/src/user.js @@ -0,0 +1,15 @@ +function createUser(input) { + if (!input.name) { + throw new Error("name required"); + } + return { id: 1, name: input.name, age: input.age, password: input.password }; +} + +function getUser(id) { + if (id === 404) { + throw new Error("not found"); + } + return { id, name: "tea" }; +} + +module.exports = { createUser, getUser }; diff --git a/demo/jest_runtime/tests/setup.cjs b/demo/jest_runtime/tests/setup.cjs new file mode 100644 index 0000000..46f2e03 --- /dev/null +++ b/demo/jest_runtime/tests/setup.cjs @@ -0,0 +1,2 @@ +globalThis.teaForgeDemoSetupLoaded = true; +globalThis.teaForgeDemoPassword = "demo-secret"; diff --git a/demo/jest_runtime/tests/user.test.js b/demo/jest_runtime/tests/user.test.js new file mode 100644 index 0000000..76ca5ff --- /dev/null +++ b/demo/jest_runtime/tests/user.test.js @@ -0,0 +1,30 @@ +const { createUser, getUser } = require("../src/user"); + +describe("legacy user tests", () => { + test("captures runtime values from variables", () => { + expect(globalThis.teaForgeDemoSetupLoaded).toBe(true); + const input = { name: "tea", age: 20, password: globalThis.teaForgeDemoPassword }; + const expected = { id: 1, name: input.name, age: input.age, password: input.password }; + const actual = createUser(input); + + expect(actual).toEqual(expected); + expect(actual.name).not.toBe("coffee"); + }); + + test("captures resolved values", async () => { + const actual = Promise.resolve(createUser({ name: "matcha", age: 30 })); + await expect(actual).resolves.toEqual({ id: 1, name: "matcha", age: 30 }); + }); + + test("rejects a missing user name", () => { + expect(() => createUser({ age: 20 })).toThrow("name required"); + }); + + test("captures thrown expectations", () => { + expect(() => getUser(404)).toThrow("not found"); + }); + + test("gets an existing user", () => { + expect(getUser(1)).toEqual({ id: 1, name: "tea" }); + }); +}); diff --git a/docs/adr/0001-project-local-test-execution.md b/docs/adr/0001-project-local-test-execution.md new file mode 100644 index 0000000..830f747 --- /dev/null +++ b/docs/adr/0001-project-local-test-execution.md @@ -0,0 +1,22 @@ +# ADR 0001: Execute target-project test runners locally + +- Status: Accepted +- Date: 2026-07-15 + +## Context + +TeaForge can execute pytest or Jest to collect coverage and Jest assertion evidence. Invoking a network-aware package launcher such as `npx` can download an unreviewed runner, select a version different from the target lockfile, and execute from the wrong directory. Running TeaForge's own Python for an unrelated target virtual environment can likewise produce incorrect imports or coverage. + +## Decision + +- Jest is discovered only from the nearest target project or an ancestor workspace `node_modules/.bin/jest`. +- TeaForge invokes Jest directly, runs from the discovered target project root, and passes exact test paths. +- TeaForge never invokes `npx` or performs dependency installation. +- Jest runtime and coverage processes have explicit timeouts. +- Pytest coverage accepts `--python-executable` so the caller can select the target project's virtual environment without resolving away its launcher symlink. +- Pytest coverage discovers the nearest project configuration root, runs collection and JSON export from that root, and resolves relative coverage file identities against the same root. +- `teaforge doctor --path ...` performs non-mutating discovery before generation. + +## Consequences + +The target project's package-manager installation step is explicit and reproducible. Missing runners fail with an actionable error rather than causing implicit network access. Callers are responsible for selecting a trusted target environment and an appropriate timeout. diff --git a/docs/adr/0002-versioned-atomic-artifacts.md b/docs/adr/0002-versioned-atomic-artifacts.md new file mode 100644 index 0000000..0851238 --- /dev/null +++ b/docs/adr/0002-versioned-atomic-artifacts.md @@ -0,0 +1,20 @@ +# ADR 0002: Version and atomically replace text artifacts + +- Status: Accepted +- Date: 2026-07-15 + +## Context + +TeaForge writes reports that may be consumed by people, scripts, and coding agents. Partial files, silent schema drift, or a mixed set of newly rendered and stale outputs can make a report look valid while containing inconsistent evidence. + +## Decision + +- PCL JSON and embedded coverage data carry an explicit `schema_version`. Coverage schema 2 also records the evidence provider and metric definitions. +- PCL loading accepts the known legacy form and rejects unknown future schemas. +- Text output is first written to a same-directory temporary file, flushed, synchronized, and atomically replaced. +- Multi-document PCL and coverage workflows parse and render all requested documents before replacing output files. +- Missing custom templates fail explicitly rather than falling back to packaged templates. + +## Consequences + +An interrupted individual write preserves the previous artifact. Schema incompatibility is visible instead of being guessed. The current guarantee is not a transaction across multiple files, and concurrent writers targeting the same path remain unsupported; those limitations must stay documented until a manifest/transaction protocol is introduced. diff --git a/docs/adr/0003-structural-javascript-evidence.md b/docs/adr/0003-structural-javascript-evidence.md new file mode 100644 index 0000000..2b00090 --- /dev/null +++ b/docs/adr/0003-structural-javascript-evidence.md @@ -0,0 +1,20 @@ +# ADR 0003: Isolate JavaScript syntax evidence behind Tree-sitter facts + +- Status: Accepted +- Date: 2026-07-15 + +## Context + +TeaForge must infer imports, Jest test identity, calls, exported subjects, and source-function spans without executing a target project. Regex and delimiter scanning can mistake comments or strings for code, diverge between Jest, Angular, Playwright, and coverage adapters, and lose TypeScript/TSX structure. Installing a parser into every target project would violate the local-runner boundary. + +## Decision + +- TeaForge declares Python Tree-sitter plus JavaScript and TypeScript grammars as its own base dependencies. +- A single JavaScript Syntax Evidence module parses `.js`, `.jsx`, `.mjs`, `.cjs`, `.ts`, `.tsx`, `.mts`, and `.cts` inputs. +- The module exposes stable facts for imports, Jest cases and lexical full names, calls, exported symbols, and source-function spans. Adapter code does not receive raw Tree-sitter nodes. +- Syntax errors carry a stable `javascript-syntax-error` code and source location instead of silently falling back to regex matches. +- The parser never executes code and never reads or changes target-project package dependencies. + +## Consequences + +Comments and strings cannot create false import or test evidence, and all JavaScript-facing adapters share one grammar boundary. The Python wheel grows by the grammar dependencies. Tree-sitter proves syntax structure only: TeaForge still does not type-check TypeScript, evaluate dynamic imports, or execute computed test construction; runtime evidence remains the appropriate fallback for dynamic values. diff --git a/docs/adr/0004-bounded-external-processes.md b/docs/adr/0004-bounded-external-processes.md new file mode 100644 index 0000000..764c74f --- /dev/null +++ b/docs/adr/0004-bounded-external-processes.md @@ -0,0 +1,22 @@ +# ADR 0004: Own external process deadlines and cleanup centrally + +- Status: Accepted +- Date: 2026-07-15 + +## Context + +TeaForge runs target-project Jest, target-environment coverage.py, and Mermaid CLI. A timeout applied only to the immediate process can leave worker, browser, or test child processes alive. Capturing arbitrary stdout and stderr in memory can also exhaust the TeaForge process before its diagnostic truncation runs. + +## Decision + +- All external commands pass through one External Process module; adapters do not call `subprocess.run` directly. +- Commands execute without a shell and receive an explicit workflow deadline. +- Multi-stage adapters create one monotonic `WorkflowDeadline`; later commands receive only its remaining budget and are not started after it expires. +- stdout and stderr are drained concurrently while retaining only bounded head/tail diagnostics. +- POSIX commands start in a new session and TeaForge signals the whole process group on timeout. +- Windows commands use a kill-on-close Job Object when available, then `taskkill /T` tree cleanup and direct-process termination as progressively narrower fallbacks. +- Results expose command, exit code, duration, output, and truncation state. Timeouts carry a stable `process-timeout` code and their bounded partial result. + +## Consequences + +Jest workers and Mermaid's browser no longer outlive an expired TeaForge workflow under the supported process-group mechanisms, and noisy tools cannot grow captured output without bound. Output intended as a machine payload must fit the configured capture limit or be rejected by its downstream parser. Platform process APIs add implementation complexity, so POSIX descendant cleanup is covered by an executable test and Windows Job Object setup fails safely to direct-process cleanup when unavailable. diff --git a/pyproject.toml b/pyproject.toml index c313658..6ddb1a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,25 +4,49 @@ build-backend = "setuptools.build_meta" [project] name = "teaforge" -version = "0.1.0" -description = "Generate Program Check List (PCL) documents from pytest tests." +version = "0.2.0" +description = "Generate PCL test specifications and coverage reports from pytest, Jest, Angular, and Playwright tests." readme = "README.md" license = "MIT" +authors = [{name = "Magnus Wong"}] requires-python = ">=3.11" +keywords = ["testing", "pytest", "jest", "coverage", "pcl", "mermaid"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Software Development :: Testing", +] dependencies = [ "typer>=0.12,<1.0", "jinja2>=3.1,<4.0", "coverage>=7,<8", + "pytest>=7,<9", + "tree-sitter>=0.25,<0.27", + "tree-sitter-javascript>=0.25,<0.26", + "tree-sitter-typescript>=0.23,<0.24", ] [project.optional-dependencies] pdf = ["weasyprint>=62,<70"] dev = [ - "pytest>=8.2,<9.0", + "build>=1.2,<2.0", "fastapi>=0.115,<1.0", "httpx>=0.27,<1.0", + "ruff>=0.9,<1.0", + "twine>=5,<7", ] +[project.urls] +Homepage = "https://github.com/cyberyimein/TeaForge" +Repository = "https://github.com/cyberyimein/TeaForge" +Issues = "https://github.com/cyberyimein/TeaForge/issues" + [project.scripts] teaforge = "teaforge.cli:app" @@ -32,6 +56,10 @@ package-dir = {"" = "src"} [tool.setuptools.packages.find] where = ["src"] +[tool.setuptools.package-data] +"teaforge.templates" = ["*.html"] +"teaforge.jest.assets" = ["*.cjs"] + [tool.pytest.ini_options] pythonpath = ["src", "."] testpaths = ["tests", "demo/fastapi_crud/tests"] @@ -40,3 +68,18 @@ markers = [ "boundary: Boundary value test", "exceptional: Exceptional value test", ] + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I"] + +[tool.coverage.run] +branch = true +source = ["teaforge"] + +[tool.coverage.report] +fail_under = 80 +show_missing = true diff --git a/skill/skill.md b/skill/SKILL.md similarity index 65% rename from skill/skill.md rename to skill/SKILL.md index 83c406c..37a6edb 100644 --- a/skill/skill.md +++ b/skill/SKILL.md @@ -1,17 +1,18 @@ --- name: TeaForge -description: "Generate Japanese-style unit tests, PCL documents, and Mermaid coverage reports using TeaForge CLI. USE FOR: writing pytest or jest tests with C1 branch coverage; generating PCL HTML/PDF from pytest or TypeScript/Jest files; creating Mermaid flowchart coverage reports for business functions; deciding which functions need flowcharts. DO NOT USE FOR: general application debugging; non-test code generation." +description: "Generate Japanese-style unit tests, runtime-backed PCL documents, and Mermaid coverage reports using TeaForge CLI. USE FOR: writing pytest or Jest tests with C1 branch coverage; capturing Jest expected/actual evidence; generating PCL HTML/PDF; creating Mermaid flowchart or sequence-diagram pages. DO NOT USE FOR: general application debugging; non-test code generation." argument-hint: "Describe the target module, function, and framework (pytest or jest)" --- # TeaForge -TeaForge CLI converts automated tests into readable, exportable, queryable PCL documents and generates coverage reports with Mermaid flowcharts. +TeaForge CLI converts automated tests into readable, exportable, queryable PCL documents and generates coverage reports with Mermaid flowcharts and sequence diagrams. ## When to Use - Writing unit tests that follow Japanese-style testing methodology (parameter × branch full coverage) - Generating PCL (test-specification) documents from existing pytest or Jest files +- Capturing observed Jest matcher evidence when static parsing cannot resolve values - Producing coverage reports with Mermaid flowchart diagrams - Deciding which functions deserve flowcharts in coverage reports - Exporting test documentation to PDF @@ -27,11 +28,23 @@ TeaForge CLI converts automated tests into readable, exportable, queryable PCL d ### jest / TypeScript - Enabled through `--framework jest` -- Current parser support focuses on a constrained but practical subset: - `test(...)`, `it(...)`, `test.only(...)`, `it.only(...)`, `test.each([...])(...)` +- TeaForge-packaged Tree-sitter JavaScript, TypeScript, and TSX grammars provide structural import, test, call, exported-symbol, and source-function evidence +- Current test support focuses on a constrained but practical subset: + `test(...)`, `it(...)`, `test.only(...)`, `it.only(...)`, nested `describe`, array-literal `test.each([...])(...)`, and `describe.each([...])(...)` - Current coverage support expects Jest to emit Istanbul `coverage-final.json` -- Current import resolution focuses on relative local imports +- Current import resolution supports relative ESM imports and common CommonJS `require` forms - Current source parsing supports top-level function declarations, arrow functions, and class methods +- Use `--evidence-mode runtime` to record matcher, expected, actual, pass/fail, `.not`, Promise, and thrown-error evidence from the project-local Jest +- Use `--evidence-mode auto` when runtime evidence is preferred but static fallback is acceptable +- Runtime evidence supplements the static design expectation instead of overwriting it +- Runtime execution uses only the discovered project-local or workspace-hoisted Jest; TeaForge never invokes `npx` or downloads a runner +- Runtime values apply default sensitive-key/common credential redaction, value truncation, and bounded evidence limits; producer warnings are preserved in the PCL + +### angular / Playwright + +- Use `--framework angular` for Angular/Jest page semantics and Angular coverage +- Use `--framework playwright` for page actions and UI/navigation PCL extraction +- Playwright coverage is not currently supported ## Japanese-Style Unit Testing Methodology @@ -65,13 +78,14 @@ Cover every execution path through the function: | Command | Purpose | |---------|---------| +| `teaforge doctor --framework --path --python-executable --json` | Check packaged resources, target interpreter/test-runner discovery, and optional renderer capabilities without running tests | | `teaforge pcl generate --path --output ` | Generate PCL document from test file or directory | -| `teaforge pcl generate --framework jest --path --output ` | Generate PCL document from Jest / TypeScript tests | +| `teaforge pcl generate --framework jest --evidence-mode runtime --path --output ` | Generate PCL with observed Jest assertion evidence | | `teaforge export --path --output ` | Export HTML report to PDF | | `teaforge get --path --testcase ` | Query a specific test case | | `teaforge mermaid validate --code ` | Validate Mermaid syntax | -| `teaforge mermaid generate --source --function --code --output-dir ` | Persist and render a Mermaid diagram | -| `teaforge coverage generate --path --output --function --diagram-dir ` | Generate coverage report with flowcharts | +| `teaforge mermaid generate --source --function --diagram-type --code --output-dir ` | Validate and persist a typed Mermaid source file | +| `teaforge coverage generate --path --output --function --sequence --diagram-dir --min-c0 --min-c1 ` | Generate coverage report with flowchart/sequence pages and optional file-level gates | | `teaforge coverage generate --framework jest --path --output --function --diagram-dir ` | Generate a Jest / TypeScript coverage report | ## Procedure @@ -83,28 +97,29 @@ Cover every execution path through the function: 3. Map every branch (`if/else`, `try/except`, early return) in the function. 4. Write a baseline test for the normal path with normal parameters. 5. Derive variant tests — change exactly one input or trigger exactly one different branch per test. -6. Verify C1 coverage reaches 100% using `teaforge coverage generate`. +6. Inspect the C1 result from `teaforge coverage generate` and add tests until all intended branches are covered. In CI, pass `--min-c0` and `--min-c1`; an unmet gate preserves the reports and exits with code `3`. ### Choosing the Framework 1. Use the default `pytest` flow for Python tests. 2. Use `--framework jest` for Node.js / TypeScript Jest tests. 3. Keep the framework choice consistent between PCL generation and coverage generation. +4. Before automation, run `teaforge doctor --framework --path --json` and stop if `ready` is false. ### Generating Coverage Reports (End-to-End) -Coverage reports require Mermaid flowcharts for selected business functions. The AI is responsible for **choosing which functions need flowcharts**, **writing the Mermaid code**, and **invoking the CLI**. Follow these steps: +Coverage reports accept Mermaid flowcharts and sequence diagrams for selected business functions. The AI is responsible for choosing the useful diagram type, writing the Mermaid code, and invoking the CLI. 1. **Read the source file** under test to understand all function signatures and bodies. -2. **Select business functions** that need flowcharts (see Flowchart Selection below). -3. **Write Mermaid flowchart code** for each selected function (see Writing Mermaid Flowcharts below). +2. **Select business functions** that need flowcharts, sequence diagrams, or both. +3. **Write Mermaid code** for each selected function. 4. **Validate** each diagram: `teaforge mermaid validate --code ""`. 5. **Persist** each diagram: `teaforge mermaid generate --source --function --code "" --output-dir `. 6. **Generate the report**: - pytest: `teaforge coverage generate --path --output --function --function --diagram-dir ` - jest: `teaforge coverage generate --framework jest --path --output --function --function --diagram-dir ` -The `--function` flag is repeatable — pass it once per function that has a flowchart. +The `--function` flag adds flowchart pages and `--sequence` adds sequence-diagram pages. Both are repeatable and may target the same function. ### Jest-Specific Notes @@ -112,6 +127,15 @@ The `--function` flag is repeatable — pass it once per function that has a flo - The AI should still follow the same Japanese PCL principle: derive neighboring boundary / abnormal cases from one normal baseline. - TeaForge currently expects Jest row data to be statically readable array literals. - If the parser cannot prove the business source file under test, PCL generation may still fallback for display, but coverage generation will fail fast. +- Prefer runtime evidence for older or dynamically written tests whose variables cannot be resolved statically. It requires an already-installed project-local Jest and intentionally never downloads one. +- A completed Jest run with failing tests still writes the evidence report and returns exit code `2`. Do not treat it as a TeaForge generation error; either fix the test or deliberately pass `--allow-test-failures`. +- Use `--runtime-timeout` to bound Jest execution. For pytest coverage in another virtual environment, pass `--python-executable `. + +### Sequence Diagram Selection + +Use a sequence diagram when the important fact is interaction order across callers, business logic, persistence, or external systems. Keep flowcharts for internal branches and exits. TeaForge expects the AI to provide `sequenceDiagram` Mermaid; assertion values alone are not sufficient to infer a trustworthy call trace. + +Generate it with `--diagram-type sequence`, then include it in coverage with `--sequence `. ### Flowchart Selection diff --git a/src/teaforge/__init__.py b/src/teaforge/__init__.py index 84b6217..ae3175a 100644 --- a/src/teaforge/__init__.py +++ b/src/teaforge/__init__.py @@ -1,4 +1,3 @@ __all__ = ["__version__"] -__version__ = "0.1.0" - +__version__ = "0.2.0" diff --git a/src/teaforge/angular/coverage.py b/src/teaforge/angular/coverage.py index 392d9e4..6b98343 100644 --- a/src/teaforge/angular/coverage.py +++ b/src/teaforge/angular/coverage.py @@ -2,14 +2,11 @@ from __future__ import annotations -import json -import subprocess -import tempfile from pathlib import Path from typing import TYPE_CHECKING from teaforge.angular.parser import parse_angular_documents -from teaforge.jest.coverage import _extract_snapshots, parse_jest_source_functions +from teaforge.jest.coverage import _analyze_jest_coverage, parse_jest_source_functions if TYPE_CHECKING: from teaforge.coverage.analyzer import SourceCoverageSnapshot, SourceFunction @@ -43,53 +40,16 @@ def parse_angular_source_functions(source_path: Path) -> list[SourceFunction]: def analyze_angular_coverage( test_path: Path, source_paths: list[Path], + *, + timeout_seconds: int = 120, ) -> dict[Path, SourceCoverageSnapshot]: """Run Jest/Istanbul coverage for Angular page-level tests.""" - resolved_test_path = test_path.resolve() - resolved_sources = [path.resolve() for path in source_paths] - - with tempfile.TemporaryDirectory(prefix="teaforge-angular-coverage-") as temp_dir: - temp_root = Path(temp_dir) - coverage_dir = temp_root / "coverage" - coverage_dir.mkdir(parents=True, exist_ok=True) - coverage_file = coverage_dir / "coverage-final.json" - - try: - run_result = subprocess.run( - [ - "npx", - "jest", - "--coverage", - "--coverageReporters=json", - "--coverageDirectory", - str(coverage_dir), - "--runInBand", - str(resolved_test_path), - ], - capture_output=True, - text=True, - check=False, - ) - except FileNotFoundError as exc: - raise RuntimeError( - "Angular coverage execution requires `npx` and Jest. Install Node.js and Jest before running coverage reports." - ) from exc - - if run_result.returncode != 0: - detail = (run_result.stderr or run_result.stdout).strip() - raise RuntimeError( - "Angular/Jest failed while collecting coverage data. " - f"Exit code: {run_result.returncode}. {detail}" - ) - - if not coverage_file.exists(): - raise RuntimeError( - "Angular/Jest did not emit coverage-final.json. Ensure Istanbul JSON output is available." - ) - - payload = json.loads(coverage_file.read_text(encoding="utf-8")) - - return _extract_snapshots(payload, resolved_sources) + return _analyze_jest_coverage( + test_path, + source_paths, + timeout_seconds=timeout_seconds, + operation="Angular/Jest coverage collection", + ) def _document_uses_test_file_as_source(document) -> bool: diff --git a/src/teaforge/angular/parser.py b/src/teaforge/angular/parser.py index 532a4d7..36c95de 100644 --- a/src/teaforge/angular/parser.py +++ b/src/teaforge/angular/parser.py @@ -8,8 +8,8 @@ from urllib.parse import parse_qsl, urlsplit from teaforge.jest.parser import ( - JSImport, JestTestBlock, + JSImport, SubjectLocation, _collect_test_files, _extract_imports, @@ -19,9 +19,10 @@ _split_top_level, _stringify_value, ) +from teaforge.pcl.assembly import build_input_rows, build_output_rows, merge_documents_by_subject from teaforge.pcl.classification import classify_type from teaforge.pcl.models import PCLDocument, PCLTestCase -from teaforge.pcl.parser import _build_input_rows, _build_output_rows, _display_path, _merge_documents_by_subject +from teaforge.pcl.parser import _display_path def parse_angular_documents(test_path: Path) -> list[PCLDocument]: @@ -34,13 +35,20 @@ def parse_angular_documents(test_path: Path) -> list[PCLDocument]: for file_path in files: content = file_path.read_text(encoding="utf-8") imports = _extract_imports(file_path, content) - for index, block in enumerate(_extract_test_blocks(content), start=1): + for index, block in enumerate( + _extract_test_blocks( + content, + suffix=file_path.suffix, + source_name=str(file_path), + ), + start=1, + ): raw_documents.append(_build_document_for_block(file_path, block, imports, index)) if not raw_documents: raise ValueError(f"No supported Angular tests found under: {test_path}") - return _merge_documents_by_subject(raw_documents) + return merge_documents_by_subject(raw_documents) def _build_document_for_block( @@ -83,10 +91,11 @@ def _build_document_for_block( navigation_outputs=navigation_outputs, verification_mode="unit", output_checks=output_checks, + test_full_name=block.full_name or block.title, ) ) - document.input_rows = _build_input_rows(document.testcases) - document.output_rows = _build_output_rows(document.testcases) + document.input_rows = build_input_rows(document.testcases) + document.output_rows = build_output_rows(document.testcases) return document diff --git a/src/teaforge/artifacts.py b/src/teaforge/artifacts.py new file mode 100644 index 0000000..412e0b3 --- /dev/null +++ b/src/teaforge/artifacts.py @@ -0,0 +1,44 @@ +"""Persist generated artifacts without exposing partially written files.""" + +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path +from typing import Any + + +def json_for_html_script(payload: Any, *, indent: int | None = 2) -> str: + """Serialize JSON without allowing values to terminate an HTML script element.""" + serialized = json.dumps(payload, ensure_ascii=False, indent=indent) + return ( + serialized.replace("&", "\\u0026") + .replace("<", "\\u003c") + .replace(">", "\\u003e") + .replace("\u2028", "\\u2028") + .replace("\u2029", "\\u2029") + ) + + +def write_text_atomic(path: Path, content: str, *, encoding: str = "utf-8") -> None: + """Write and fsync a sibling temporary file before atomically replacing the target.""" + path.parent.mkdir(parents=True, exist_ok=True) + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding=encoding, + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + delete=False, + ) as handle: + temporary_path = Path(handle.name) + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_path, path) + finally: + if temporary_path is not None and temporary_path.exists(): + temporary_path.unlink() diff --git a/src/teaforge/cli.py b/src/teaforge/cli.py index 3496f0f..4365161 100644 --- a/src/teaforge/cli.py +++ b/src/teaforge/cli.py @@ -2,14 +2,17 @@ from __future__ import annotations +import json from pathlib import Path +from typing import Any -import click import typer from typer.main import get_command as get_typer_command +from teaforge import __version__ from teaforge.coverage.mermaid import save_mermaid_diagram, validate_mermaid_with_renderer -from teaforge.coverage.service import generate_coverage_reports +from teaforge.coverage.service import CoverageThresholdError, generate_coverage_reports +from teaforge.doctor import format_doctor_report, inspect_capabilities from teaforge.pcl.pdf import export_pdf_from_html from teaforge.pcl.service import generate_pcl, get_testcase_description, load_pcl_document @@ -22,6 +25,61 @@ app.add_typer(coverage_app, name="coverage") +def _error_code(error: Exception) -> str: + """Map public failures to stable codes without leaking implementation types.""" + explicit_code = getattr(error, "code", None) + if isinstance(explicit_code, str) and explicit_code: + return explicit_code + if isinstance(error, FileNotFoundError): + return "file-not-found" + if isinstance(error, ValueError): + return "invalid-input" + return "runtime-error" + + +def _emit_cli_error(error: Exception, *, json_output: bool = False) -> None: + """Keep human errors concise and JSON-mode failures machine readable.""" + if json_output: + typer.echo( + json.dumps( + { + "schema_version": 1, + "teaforge_version": __version__, + "ready": False, + "error": { + "code": _error_code(error), + "message": str(error), + }, + }, + ensure_ascii=False, + indent=2, + ) + ) + return + typer.echo(f"Error: {error}", err=True) + + +def _version_callback(value: bool) -> bool: + """Print the installed TeaForge version before command dispatch.""" + if value: + typer.echo(__version__) + raise typer.Exit() + return value + + +@app.callback() +def root_callback( + version: bool = typer.Option( + False, + "--version", + callback=_version_callback, + is_eager=True, + help="show the TeaForge version and exit", + ), +) -> None: + """Generate auditable test specifications and coverage reports.""" + + @app.command("help") def help_command( command_path: list[str] | None = typer.Argument( @@ -33,11 +91,77 @@ def help_command( try: command, info_name = _resolve_help_target(command_path or []) except ValueError as exc: - typer.echo(f"Error: {exc}", err=True) + _emit_cli_error(exc) raise typer.Exit(code=1) from exc - context = click.Context(command, info_name=info_name) - typer.echo(command.get_help(context)) + with command.make_context(info_name, [], resilient_parsing=True) as context: + help_text = command.get_help(context).rstrip() + option_names = [ + option + for parameter in command.params + for option in getattr(parameter, "opts", ()) + if option.startswith("--") + ] + typer.echo(help_text) + if option_names: + typer.echo("\nOption names: " + ", ".join(dict.fromkeys(option_names))) + + +@app.command("doctor") +def doctor_command( + framework: str = typer.Option( + "pytest", + "--framework", + help="workflow to inspect: pytest, jest, angular, or playwright", + ), + path: Path | None = typer.Option( + None, + "--path", + help="optional target test file or directory for project discovery", + ), + require_mermaid: bool = typer.Option( + False, + "--require-mermaid", + help="treat a missing Mermaid renderer as an error", + ), + require_pdf: bool = typer.Option( + False, + "--require-pdf", + help="treat an unavailable PDF renderer as an error", + ), + json_output: bool = typer.Option( + False, + "--json", + help="emit a machine-readable readiness report", + ), + timeout: int = typer.Option( + 10, + "--timeout", + min=1, + help="maximum seconds for each version/capability check", + ), + python_executable: Path | None = typer.Option( + None, + "--python-executable", + help="Python interpreter from the target project environment (pytest only)", + ), +) -> None: + """Check dependencies and target-project discovery without running tests.""" + try: + report = inspect_capabilities( + framework=framework, + test_path=path, + require_mermaid=require_mermaid, + require_pdf=require_pdf, + timeout_seconds=timeout, + python_executable=python_executable, + ) + except (OSError, RuntimeError, ValueError) as exc: + _emit_cli_error(exc, json_output=json_output) + raise typer.Exit(code=1) from exc + typer.echo(report.to_json() if json_output else format_doctor_report(report)) + if not report.ready: + raise typer.Exit(code=1) @pcl_app.command("generate") @@ -59,6 +183,22 @@ def pcl_generate( "--template", help="optional custom html template path", ), + evidence_mode: str = typer.Option( + "static", + "--evidence-mode", + help="Jest assertion evidence: static, runtime, or auto fallback", + ), + runtime_timeout: int = typer.Option( + 120, + "--runtime-timeout", + min=1, + help="maximum seconds for Jest runtime evidence collection", + ), + allow_test_failures: bool = typer.Option( + False, + "--allow-test-failures", + help="return exit code 0 after writing a report that contains failing Jest tests", + ), ) -> None: """Generate PCL HTML and JSON from pytest, Jest, Angular, or Playwright tests.""" try: @@ -68,13 +208,26 @@ def pcl_generate( json_output=json_output, template_path=template, framework=framework, + evidence_mode=evidence_mode, + runtime_timeout=runtime_timeout, ) - except (FileNotFoundError, ValueError) as exc: - typer.echo(f"Error: {exc}", err=True) + except (FileNotFoundError, RuntimeError, ValueError) as exc: + _emit_cli_error(exc) raise typer.Exit(code=1) from exc for html_path, json_path in generated_files: typer.echo(f"Generated HTML: {html_path}") typer.echo(f"Generated JSON: {json_path}") + runtime_failed = any( + load_pcl_document(json_path).runtime_tests_passed is False + for _, json_path in generated_files + ) + if runtime_failed and not allow_test_failures: + typer.echo( + "Warning: Jest tests failed. Reports were generated with failure evidence; " + "TeaForge is returning exit code 2. Use --allow-test-failures to accept this result.", + err=True, + ) + raise typer.Exit(code=2) @app.command("export") @@ -86,7 +239,7 @@ def export_command( try: export_pdf_from_html(path, output) except (RuntimeError, FileNotFoundError) as exc: - typer.echo(f"Error: {exc}", err=True) + _emit_cli_error(exc) raise typer.Exit(code=1) from exc typer.echo(f"Generated PDF: {output}") @@ -101,7 +254,7 @@ def get_command( document = load_pcl_document(path) description = get_testcase_description(document, testcase) except (FileNotFoundError, ValueError) as exc: - typer.echo(f"Error: {exc}", err=True) + _emit_cli_error(exc) raise typer.Exit(code=1) from exc typer.echo(description) @@ -118,7 +271,7 @@ def mermaid_validate( try: validate_mermaid_with_renderer(code) except (RuntimeError, ValueError) as exc: - typer.echo(f"Error: {exc}", err=True) + _emit_cli_error(exc) raise typer.Exit(code=1) from exc typer.echo("Mermaid syntax looks valid.") @@ -141,6 +294,11 @@ def mermaid_generate( "--output-dir", help="directory used to store .mmd files", ), + diagram_type: str = typer.Option( + "auto", + "--diagram-type", + help="diagram type: auto, flowchart, or sequence", + ), ) -> None: """Validate Mermaid text and save it as a per-business-function .mmd file.""" try: @@ -149,9 +307,10 @@ def mermaid_generate( source_path=source, function_name=function, output_dir=output_dir, + diagram_type=diagram_type, ) except (FileNotFoundError, RuntimeError, ValueError) as exc: - typer.echo(f"Error: {exc}", err=True) + _emit_cli_error(exc) raise typer.Exit(code=1) from exc typer.echo(f"Generated Mermaid file: {mmd_path}") @@ -170,6 +329,11 @@ def coverage_generate( "--function", help="repeat to include a business function flowchart page; only key business functions need diagrams", ), + sequence: list[str] | None = typer.Option( + None, + "--sequence", + help="repeat to include a business function sequence-diagram page", + ), diagram_dir: Path = typer.Option( Path("output/files"), "--diagram-dir", @@ -180,6 +344,31 @@ def coverage_generate( "--template", help="optional custom coverage report html template path", ), + runtime_timeout: int = typer.Option( + 120, + "--runtime-timeout", + min=1, + help="maximum seconds for test coverage collection", + ), + python_executable: Path | None = typer.Option( + None, + "--python-executable", + help="Python interpreter from the target project environment (pytest coverage only)", + ), + min_c0: float | None = typer.Option( + None, + "--min-c0", + min=0.0, + max=100.0, + help="minimum required file-level C0 percentage; writes reports and exits 3 on failure", + ), + min_c1: float | None = typer.Option( + None, + "--min-c1", + min=0.0, + max=100.0, + help="minimum required file-level C1 percentage; writes reports and exits 3 on failure", + ), ) -> None: """Generate a file-level C0/C1 report and optional flowchart pages for selected business functions.""" try: @@ -188,26 +377,37 @@ def coverage_generate( html_output=output, diagram_dir=diagram_dir, diagram_functions=function, + sequence_functions=sequence, template_path=template, framework=framework, + runtime_timeout=runtime_timeout, + python_executable=python_executable, + min_c0=min_c0, + min_c1=min_c1, ) + except CoverageThresholdError as exc: + for html_path in exc.outputs: + typer.echo(f"Generated coverage HTML: {html_path}") + typer.echo(f"Warning: {exc}", err=True) + raise typer.Exit(code=3) from exc except (FileNotFoundError, RuntimeError, ValueError) as exc: - typer.echo(f"Error: {exc}", err=True) + _emit_cli_error(exc) raise typer.Exit(code=1) from exc for html_path in generated_files: typer.echo(f"Generated coverage HTML: {html_path}") -def _resolve_help_target(command_path: list[str]) -> tuple[click.Command, str]: +def _resolve_help_target(command_path: list[str]) -> tuple[Any, str]: """Resolve a nested command path into the Click command that owns its help text.""" - command: click.Command = get_typer_command(app) + command = get_typer_command(app) resolved_path: list[str] = [] for part in command_path: - if not isinstance(command, click.Group): + commands = getattr(command, "commands", None) + if not isinstance(commands, dict): current_path = " ".join(resolved_path) or "teaforge" raise ValueError(f"Command path '{current_path}' does not accept subcommands.") - next_command = command.commands.get(part) + next_command = commands.get(part) if next_command is None: requested_path = " ".join([*resolved_path, part]) raise ValueError(f"Unknown command path: {requested_path}") diff --git a/src/teaforge/coverage/analyzer.py b/src/teaforge/coverage/analyzer.py index fbddd55..5b254e2 100644 --- a/src/teaforge/coverage/analyzer.py +++ b/src/teaforge/coverage/analyzer.py @@ -5,13 +5,16 @@ import ast import importlib.util import json -import subprocess import sys import tempfile from dataclasses import dataclass from pathlib import Path from teaforge.pcl.backends import normalize_pcl_framework, parse_documents_for_framework +from teaforge.process import WorkflowDeadline, bounded_process_detail, run_process + +CoverageBranch = tuple[int, int] | tuple[int, int, str] +PYTEST_PROJECT_MARKERS = ("pyproject.toml", "pytest.ini", "setup.cfg", "tox.ini") @dataclass(slots=True) @@ -30,8 +33,8 @@ class SourceCoverageSnapshot: c1_total: int executed_lines: set[int] missing_lines: set[int] - executed_branches: set[tuple[int, int]] - missing_branches: set[tuple[int, int]] + executed_branches: set[CoverageBranch] + missing_branches: set[CoverageBranch] def discover_source_files(test_path: Path, framework: str = "pytest") -> list[Path]: @@ -114,64 +117,93 @@ def analyze_coverage( test_path: Path, source_paths: list[Path], framework: str = "pytest", + *, + timeout_seconds: int = 120, + python_executable: Path | None = None, ) -> dict[Path, SourceCoverageSnapshot]: """Run the selected test framework and map coverage payloads back to each source file.""" normalized = normalize_pcl_framework(framework) if normalized == "jest": from teaforge.jest.coverage import analyze_jest_coverage - return analyze_jest_coverage(test_path, source_paths) + return analyze_jest_coverage( + test_path, source_paths, timeout_seconds=timeout_seconds + ) if normalized == "angular": from teaforge.angular.coverage import analyze_angular_coverage - return analyze_angular_coverage(test_path, source_paths) + return analyze_angular_coverage( + test_path, source_paths, timeout_seconds=timeout_seconds + ) if normalized == "playwright": raise ValueError("Coverage reports do not support framework: playwright") - return _analyze_pytest_coverage(test_path, source_paths) + selected_python = (python_executable or Path(sys.executable)).expanduser().absolute() + if not selected_python.is_file(): + raise FileNotFoundError( + f"Python executable does not exist: {selected_python}" + ) + return _analyze_pytest_coverage( + test_path, + source_paths, + timeout_seconds=timeout_seconds, + python_executable=selected_python, + ) def _analyze_pytest_coverage( pytest_path: Path, source_paths: list[Path], + *, + timeout_seconds: int, + python_executable: Path, ) -> dict[Path, SourceCoverageSnapshot]: """Run pytest through coverage.py and map the JSON payload back to each source file.""" _ensure_runtime_dependencies() resolved_pytest_path = pytest_path.resolve() resolved_sources = [path.resolve() for path in source_paths] + source_roots = ",".join( + dict.fromkeys(str(path.parent) for path in resolved_sources) + ) + project_root = discover_pytest_project_root(resolved_pytest_path) + deadline = WorkflowDeadline.start( + "pytest coverage workflow", + timeout_seconds, + ) with tempfile.TemporaryDirectory(prefix="teaforge-coverage-") as temp_dir: temp_root = Path(temp_dir) data_file = temp_root / ".coverage" json_output = temp_root / "coverage.json" - run_result = subprocess.run( + run_result = run_process( [ - sys.executable, + str(python_executable), "-m", "coverage", "run", "--branch", + f"--source={source_roots}", f"--data-file={data_file}", "-m", "pytest", str(resolved_pytest_path), ], - capture_output=True, - text=True, - check=False, + operation="pytest coverage collection", + timeout_seconds=deadline.remaining_seconds(), + cwd=project_root, ) if run_result.returncode != 0: - detail = (run_result.stderr or run_result.stdout).strip() + detail = bounded_process_detail(run_result.stderr, run_result.stdout) raise RuntimeError( "pytest failed while collecting coverage data. " f"Exit code: {run_result.returncode}. {detail}" ) - include_arg = ",".join(str(path) for path in resolved_sources) - json_result = subprocess.run( + include_arg = _coverage_include_argument(resolved_sources, project_root) + json_result = run_process( [ - sys.executable, + str(python_executable), "-m", "coverage", "json", @@ -180,19 +212,49 @@ def _analyze_pytest_coverage( str(json_output), f"--include={include_arg}", ], - capture_output=True, - text=True, - check=False, + operation="coverage.py JSON export", + timeout_seconds=deadline.remaining_seconds(), + cwd=project_root, ) if json_result.returncode != 0: - detail = (json_result.stderr or json_result.stdout).strip() + detail = bounded_process_detail(json_result.stderr, json_result.stdout) raise RuntimeError( "coverage.py failed to export JSON data. " f"Exit code: {json_result.returncode}. {detail}" ) payload = json.loads(json_output.read_text(encoding="utf-8")) - return _extract_snapshots(payload, resolved_sources) + return _extract_snapshots( + payload, + resolved_sources, + project_root=project_root, + ) + + +def discover_pytest_project_root(test_path: Path) -> Path: + """Find the nearest pytest configuration root, with a local-path fallback.""" + resolved = test_path.expanduser().resolve() + start = resolved if resolved.is_dir() else resolved.parent + for candidate in (start, *start.parents): + if any((candidate / marker).is_file() for marker in PYTEST_PROJECT_MARKERS): + return candidate + return start + + +def _coverage_include_argument(source_paths: list[Path], project_root: Path) -> str: + """Match coverage.py data whether it stores absolute or project-relative names.""" + patterns: list[str] = [] + for source_path in source_paths: + absolute = str(source_path) + if absolute not in patterns: + patterns.append(absolute) + try: + relative = str(source_path.relative_to(project_root)) + except ValueError: + continue + if relative not in patterns: + patterns.append(relative) + return ",".join(patterns) def _ensure_runtime_dependencies() -> None: @@ -210,11 +272,14 @@ def _ensure_runtime_dependencies() -> None: def _extract_snapshots( payload: dict, source_paths: list[Path], + *, + project_root: Path | None = None, ) -> dict[Path, SourceCoverageSnapshot]: """Convert coverage.json payloads into per-source snapshot dataclasses.""" - # coverage.py may emit relative paths; resolving them once keeps matching deterministic. + # coverage.py commonly emits paths relative to the target project's execution root. + resolution_root = (project_root or Path.cwd()).resolve() file_entries = { - Path(file_name).resolve(): file_payload + _resolve_coverage_entry_path(file_name, resolution_root): file_payload for file_name, file_payload in payload.get("files", {}).items() } @@ -244,6 +309,13 @@ def _extract_snapshots( return snapshots +def _resolve_coverage_entry_path(file_name: str, project_root: Path) -> Path: + candidate = Path(file_name) + if not candidate.is_absolute(): + candidate = project_root / candidate + return candidate.resolve() + + def _match_file_payload(file_entries: dict[Path, dict], source_path: Path) -> dict: """Find the exact coverage payload for one resolved source file.""" if source_path in file_entries: diff --git a/src/teaforge/coverage/mermaid.py b/src/teaforge/coverage/mermaid.py index 999647c..4f80991 100644 --- a/src/teaforge/coverage/mermaid.py +++ b/src/teaforge/coverage/mermaid.py @@ -2,12 +2,14 @@ from __future__ import annotations +import os import shutil -import subprocess import tempfile from pathlib import Path +from teaforge.artifacts import write_text_atomic from teaforge.naming import folder_name +from teaforge.process import bounded_process_detail, run_process MERMAID_EXAMPLE = """flowchart TD A[Start] --> B{Input valid?} @@ -28,6 +30,9 @@ "mindmap", "timeline", ) +DIAGRAM_TYPES = ("flowchart", "sequence") +DEFAULT_RENDER_TIMEOUT_SECONDS = 120 +PUPPETEER_CONFIG_ENV = "TEAFORGE_MERMAID_PUPPETEER_CONFIG" def mmdc_install_message() -> str: @@ -68,14 +73,56 @@ def validate_mermaid_with_renderer(code: str) -> str: return normalized -def diagram_file_stem(source_path: Path, function_name: str) -> str: +def detect_mermaid_diagram_type(code: str) -> str: + """Classify the Mermaid source into a report-supported diagram type.""" + normalized = validate_mermaid(code) + first_line = next(line.strip() for line in normalized.splitlines() if line.strip()) + if first_line.startswith("sequenceDiagram"): + return "sequence" + if first_line.startswith(("flowchart ", "graph ")): + return "flowchart" + raise ValueError( + "TeaForge reports currently support Mermaid flowcharts and sequence diagrams only." + ) + + +def normalize_diagram_type(diagram_type: str, code: str | None = None) -> str: + normalized = diagram_type.strip().lower() + if normalized == "auto": + if code is None: + raise ValueError("Mermaid code is required when diagram type is auto.") + return detect_mermaid_diagram_type(code) + if normalized not in DIAGRAM_TYPES: + raise ValueError( + f"Unsupported diagram type: {diagram_type}. Supported values: auto, " + + ", ".join(DIAGRAM_TYPES) + ) + if code is not None and detect_mermaid_diagram_type(code) != normalized: + raise ValueError( + f"Mermaid source does not match --diagram-type {normalized}." + ) + return normalized + + +def diagram_file_stem( + source_path: Path, + function_name: str, + diagram_type: str = "flowchart", +) -> str: """Keep Mermaid source and SVG names aligned with coverage report naming.""" - return f"{folder_name(source_path.stem)}_{folder_name(function_name)}_coverage_report" + base = f"{folder_name(source_path.stem)}_{folder_name(function_name)}" + suffix = "sequence_diagram" if diagram_type == "sequence" else "coverage_report" + return f"{base}_{suffix}" -def diagram_output_paths(diagram_dir: Path, source_path: Path, function_name: str) -> tuple[Path, Path]: +def diagram_output_paths( + diagram_dir: Path, + source_path: Path, + function_name: str, + diagram_type: str = "flowchart", +) -> tuple[Path, Path]: """Return the expected Mermaid source path and rendered SVG path for one function.""" - stem = diagram_file_stem(source_path, function_name) + stem = diagram_file_stem(source_path, function_name, diagram_type) return diagram_dir / f"{stem}.mmd", diagram_dir / f"{stem}.svg" @@ -85,12 +132,18 @@ def save_mermaid_diagram( source_path: Path, function_name: str, output_dir: Path, + diagram_type: str = "auto", ) -> Path: """Persist validated Mermaid text as the source-of-truth diagram artifact.""" + if not source_path.is_file(): + raise FileNotFoundError(f"Tested source file does not exist: {source_path}") + resolved_type = normalize_diagram_type(diagram_type, code) normalized = validate_mermaid_with_renderer(code) output_dir.mkdir(parents=True, exist_ok=True) - mmd_path, _ = diagram_output_paths(output_dir, source_path, function_name) - mmd_path.write_text(normalized, encoding="utf-8") + mmd_path, _ = diagram_output_paths( + output_dir, source_path, function_name, resolved_type + ) + write_text_atomic(mmd_path, normalized) return mmd_path @@ -107,14 +160,23 @@ def _run_mmdc(mmd_path: Path, svg_path: Path) -> None: raise RuntimeError(mmdc_install_message()) svg_path.parent.mkdir(parents=True, exist_ok=True) - result = subprocess.run( - [renderer, "-i", str(mmd_path), "-o", str(svg_path), "-b", "transparent"], - capture_output=True, - text=True, - check=False, + command = [renderer, "-i", str(mmd_path), "-o", str(svg_path), "-b", "transparent"] + puppeteer_config = os.environ.get(PUPPETEER_CONFIG_ENV) + if puppeteer_config: + config_path = Path(puppeteer_config).expanduser() + if not config_path.is_file(): + raise FileNotFoundError( + f"Mermaid Puppeteer config does not exist: {config_path}" + ) + command.extend(["--puppeteerConfigFile", str(config_path)]) + result = run_process( + command, + operation=f"Mermaid rendering for {mmd_path.name}", + timeout_seconds=DEFAULT_RENDER_TIMEOUT_SECONDS, ) + if result.returncode != 0: - detail = (result.stderr or result.stdout).strip() + detail = bounded_process_detail(result.stderr, result.stdout) if "Parse error" in detail: raise ValueError( "Invalid Mermaid syntax. Please fix the diagram and try again.\n" diff --git a/src/teaforge/coverage/models.py b/src/teaforge/coverage/models.py index 3756355..8577ec8 100644 --- a/src/teaforge/coverage/models.py +++ b/src/teaforge/coverage/models.py @@ -6,6 +6,8 @@ from datetime import UTC, datetime from typing import Any +COVERAGE_SCHEMA_VERSION = 2 + @dataclass(slots=True) class CoverageMetric: @@ -13,6 +15,19 @@ class CoverageMetric: total: int percent: float missing: int = 0 + applicable: bool = field(init=False) + + def __post_init__(self) -> None: + self.applicable = self.total > 0 + + +@dataclass(slots=True) +class DiagramArtifact: + kind: str + page_number: int + mermaid_path: str + svg_path: str + svg_data_uri: str @dataclass(slots=True) @@ -29,6 +44,7 @@ class FunctionCoverage: mermaid_path: str = "" svg_path: str = "" svg_data_uri: str = "" + diagrams: list[DiagramArtifact] = field(default_factory=list) @dataclass(slots=True) @@ -41,6 +57,10 @@ class CoverageDocument: page_count: int c0: CoverageMetric c1: CoverageMetric + evidence_source: str + c0_definition: str + c1_definition: str + schema_version: int = COVERAGE_SCHEMA_VERSION requested_functions: list[str] = field(default_factory=list) functions: list[FunctionCoverage] = field(default_factory=list) @@ -55,17 +75,29 @@ def create( c1: CoverageMetric, requested_functions: list[str], functions: list[FunctionCoverage], + evidence_source: str, + c0_definition: str, + c1_definition: str, ) -> "CoverageDocument": - """Create a report document with a stable page count for summary and flowchart pages.""" + """Create a report document with a stable page count for summary and diagram pages.""" + diagram_pages = sum(len(function.diagrams) for function in functions) + if diagram_pages == 0: + diagram_pages = sum( + 1 for function in functions if function.page_number is not None + ) return cls( title="Coverage Report", file=file, source_path=source_path, test_path=test_path, generated_at=datetime.now(UTC).isoformat(), - page_count=max(1, 1 + sum(1 for function in functions if function.page_number is not None)), + page_count=max(1, 1 + diagram_pages), c0=c0, c1=c1, + evidence_source=evidence_source, + c0_definition=c0_definition, + c1_definition=c1_definition, + schema_version=COVERAGE_SCHEMA_VERSION, requested_functions=requested_functions, functions=functions, ) diff --git a/src/teaforge/coverage/render.py b/src/teaforge/coverage/render.py index eec4e7d..5561d8c 100644 --- a/src/teaforge/coverage/render.py +++ b/src/teaforge/coverage/render.py @@ -2,17 +2,21 @@ from __future__ import annotations -import json +from importlib import resources from pathlib import Path from jinja2 import Environment, FileSystemLoader, select_autoescape +from teaforge.artifacts import json_for_html_script + from .models import CoverageDocument def default_template_path() -> Path: """Return the built-in coverage report template path.""" - return Path(__file__).resolve().parents[3] / "templates" / "coverage_report.html" + return Path( + str(resources.files("teaforge.templates").joinpath("coverage_report.html")) + ) def render_coverage_html( @@ -20,14 +24,25 @@ def render_coverage_html( template_path: Path | None = None, ) -> str: """Render one coverage document and embed the JSON payload for downstream tooling.""" - template_file = template_path or default_template_path() - env = Environment( - loader=FileSystemLoader(str(template_file.parent)), - autoescape=select_autoescape(["html", "xml"]), - ) - template = env.get_template(template_file.name) + if template_path is None: + env = Environment(autoescape=select_autoescape(["html", "xml"])) + template = env.from_string( + resources.files("teaforge.templates") + .joinpath("coverage_report.html") + .read_text(encoding="utf-8") + ) + else: + if not template_path.is_file(): + raise FileNotFoundError( + f"Coverage template does not exist: {template_path}" + ) + env = Environment( + loader=FileSystemLoader(str(template_path.parent)), + autoescape=select_autoescape(["html", "xml"]), + ) + template = env.get_template(template_path.name) payload = document.to_dict() - embedded_json = json.dumps(payload, ensure_ascii=False, indent=2) + embedded_json = json_for_html_script(payload) return template.render( document=payload, embedded_json=embedded_json, diff --git a/src/teaforge/coverage/service.py b/src/teaforge/coverage/service.py index f6e99ee..07bb772 100644 --- a/src/teaforge/coverage/service.py +++ b/src/teaforge/coverage/service.py @@ -4,9 +4,11 @@ import base64 import re +from dataclasses import dataclass from pathlib import Path -from teaforge.naming import folder_name +from teaforge.artifacts import write_text_atomic +from teaforge.naming import folder_name, source_folder_names from .analyzer import ( SourceCoverageSnapshot, @@ -16,77 +18,192 @@ parse_source_functions, ) from .mermaid import diagram_output_paths, render_mermaid_svg -from .models import CoverageDocument, CoverageMetric, FunctionCoverage +from .models import CoverageDocument, CoverageMetric, DiagramArtifact, FunctionCoverage from .render import render_coverage_html +@dataclass(slots=True, frozen=True) +class CoverageThresholdViolation: + output_path: Path + metric: str + actual: float + required: float + + def message(self) -> str: + return ( + f"{self.output_path}: {self.metric} {self.actual:.1f}% is below " + f"the required {self.required:.1f}%" + ) + + +class CoverageThresholdError(RuntimeError): + """Signal that reports were written but one or more coverage gates failed.""" + + def __init__( + self, + outputs: list[Path], + violations: list[CoverageThresholdViolation], + ) -> None: + self.outputs = tuple(outputs) + self.violations = tuple(violations) + details = "\n".join(f"- {item.message()}" for item in violations) + super().__init__(f"Coverage thresholds were not met:\n{details}") + + def generate_coverage_reports( *, pytest_path: Path, html_output: Path, diagram_dir: Path, diagram_functions: list[str] | None = None, + sequence_functions: list[str] | None = None, template_path: Path | None = None, framework: str = "pytest", + runtime_timeout: int = 120, + python_executable: Path | None = None, + min_c0: float | None = None, + min_c1: float | None = None, ) -> list[Path]: """Generate one report per source file, plus optional function flowchart pages.""" if not pytest_path.exists(): raise FileNotFoundError(f"Input path does not exist: {pytest_path}") + _validate_threshold("min_c0", min_c0) + _validate_threshold("min_c1", min_c1) source_paths = discover_source_files(pytest_path, framework=framework) function_index = { source_path: parse_source_functions(source_path, framework=framework) for source_path in source_paths } _ensure_functions(function_index) - selected_names = _normalize_requested_functions(diagram_functions) - resolved_selected_names = _resolve_selected_names(function_index, selected_names) - selected_index = _build_selected_index(function_index, resolved_selected_names) - _ensure_diagrams(selected_index, diagram_dir) - - snapshots = analyze_coverage(pytest_path, source_paths, framework=framework) + flowchart_names = _resolve_selected_names( + function_index, _normalize_requested_functions(diagram_functions) + ) + sequence_names = _resolve_selected_names( + function_index, _normalize_requested_functions(sequence_functions) + ) + flowchart_index = _build_selected_index(function_index, flowchart_names) + sequence_index = _build_selected_index(function_index, sequence_names) + _ensure_diagrams(flowchart_index, diagram_dir, "flowchart") + _ensure_diagrams(sequence_index, diagram_dir, "sequence") + + snapshots = analyze_coverage( + pytest_path, + source_paths, + framework=framework, + timeout_seconds=runtime_timeout, + python_executable=python_executable, + ) html_output.parent.mkdir(parents=True, exist_ok=True) - outputs: list[Path] = [] + prepared_outputs: list[tuple[Path, str, CoverageDocument]] = [] multiple_outputs = len(source_paths) > 1 + source_folders = source_folder_names(source_paths) for source_path in source_paths: snapshot = snapshots[source_path.resolve()] functions = function_index[source_path] - selected_for_source = {function.name for function in selected_index[source_path]} + flowcharts_for_source = { + function.name for function in flowchart_index[source_path] + } + sequences_for_source = { + function.name for function in sequence_index[source_path] + } page_number = 2 sections: list[FunctionCoverage] = [] for function in functions: - include_diagram = function.name in selected_for_source + diagram_types: list[str] = [] + if function.name in flowcharts_for_source: + diagram_types.append("flowchart") + if function.name in sequences_for_source: + diagram_types.append("sequence") + page_numbers = list(range(page_number, page_number + len(diagram_types))) sections.append( _build_function_section( source_path=source_path, function=function, snapshot=snapshot, diagram_dir=diagram_dir, - page_number=page_number if include_diagram else None, - include_diagram=include_diagram, + diagram_types=diagram_types, + page_numbers=page_numbers, ) ) - if include_diagram: - page_number += 1 + page_number += len(diagram_types) document = CoverageDocument.create( file=source_path.name, source_path=str(source_path), test_path=str(pytest_path.resolve()), c0=_build_metric(snapshot.c0_covered, snapshot.c0_total), c1=_build_metric(snapshot.c1_covered, snapshot.c1_total), - requested_functions=[function.name for function in selected_index[source_path]], + requested_functions=sorted(flowcharts_for_source | sequences_for_source), functions=sections, + evidence_source=_coverage_evidence_source(framework), + c0_definition="covered statement lines / measurable statement lines", + c1_definition="executed branch destinations / measurable branch destinations", ) - html_path = _resolve_output_path(source_path, html_output, multiple_outputs) - html_path.parent.mkdir(parents=True, exist_ok=True) - html_path.write_text( - render_coverage_html(document, template_path=template_path), - encoding="utf-8", + html_path = _resolve_output_path( + source_path, + html_output, + multiple_outputs, + source_folders[source_path.resolve()], ) + prepared_outputs.append( + ( + html_path, + render_coverage_html(document, template_path=template_path), + document, + ) + ) + + outputs: list[Path] = [] + for html_path, html_content, _document in prepared_outputs: + write_text_atomic(html_path, html_content) outputs.append(html_path) + violations = find_coverage_threshold_violations( + [(path, document) for path, _html, document in prepared_outputs], + min_c0=min_c0, + min_c1=min_c1, + ) + if violations: + raise CoverageThresholdError(outputs, violations) return outputs +def find_coverage_threshold_violations( + reports: list[tuple[Path, CoverageDocument]], + *, + min_c0: float | None, + min_c1: float | None, +) -> list[CoverageThresholdViolation]: + """Evaluate file-level coverage metrics for deterministic CI gating.""" + violations: list[CoverageThresholdViolation] = [] + for output_path, document in reports: + for metric_name, metric, required in ( + ("C0", document.c0, min_c0), + ("C1", document.c1, min_c1), + ): + if required is not None and metric.applicable and metric.percent < required: + violations.append( + CoverageThresholdViolation( + output_path=output_path, + metric=metric_name, + actual=metric.percent, + required=required, + ) + ) + return violations + + +def _validate_threshold(name: str, value: float | None) -> None: + if value is not None and not 0 <= value <= 100: + raise ValueError(f"{name} must be between 0 and 100: {value}") + + +def _coverage_evidence_source(framework: str) -> str: + normalized = framework.strip().lower() + if normalized == "pytest": + return "coverage.py JSON" + return "Jest/Istanbul coverage-final.json" + + def _ensure_functions(function_index: dict[Path, list[SourceFunction]]) -> None: """Fail early when a resolved source file contains no measurable functions.""" empty_sources = [str(source_path) for source_path, functions in function_index.items() if not functions] @@ -160,7 +277,11 @@ def _build_selected_index( } -def _ensure_diagrams(function_index: dict[Path, list[SourceFunction]], diagram_dir: Path) -> None: +def _ensure_diagrams( + function_index: dict[Path, list[SourceFunction]], + diagram_dir: Path, + diagram_type: str, +) -> None: """Require Mermaid source files for every function selected for diagram pages.""" if not any(functions for functions in function_index.values()): return @@ -169,7 +290,9 @@ def _ensure_diagrams(function_index: dict[Path, list[SourceFunction]], diagram_d for source_path, functions in function_index.items(): missing_functions = [] for function in functions: - mmd_path, _ = _resolve_diagram_paths(diagram_dir, source_path, function.name) + mmd_path, _ = _resolve_diagram_paths( + diagram_dir, source_path, function.name, diagram_type + ) if not mmd_path.exists(): missing_functions.append(function.name) if missing_functions: @@ -178,17 +301,29 @@ def _ensure_diagrams(function_index: dict[Path, list[SourceFunction]], diagram_d if not missing: return - lines = ["Missing Mermaid flowcharts for the coverage report:"] + diagram_label = "flowcharts" if diagram_type == "flowchart" else "sequence diagrams" + lines = [f"Missing Mermaid {diagram_label} for the coverage report:"] for source_path, function_names in missing.items(): lines.append(f"- {source_path}: {', '.join(function_names)}") first_source, first_functions = next(iter(missing.items())) lines.append("") lines.append("Generate only the required business-function diagrams before creating the report. Example:") + example_code = ( + "sequenceDiagram\\n participant Client\\n participant Target\\n" + " Client->>Target: Call business function\\n" + " Target-->>Client: Return result" + if diagram_type == "sequence" + else "flowchart TD\\n A[Start] --> B{Valid?}\\n" + " B -- Yes --> C[Execute business logic]\\n" + " B -- No --> D[Return validation error]\\n" + " C --> E[Return result]" + ) lines.append( "teaforge mermaid generate " f"--source {first_source} " f"--function {first_functions[0]} " - '--code "flowchart TD\\n A[Start] --> B{Valid?}\\n B -- Yes --> C[Execute business logic]\\n B -- No --> D[Return validation error]\\n C --> E[Return result]" ' + f"--diagram-type {diagram_type} " + f'--code "{example_code}" ' f"--output-dir {diagram_dir}" ) raise ValueError("\n".join(lines)) @@ -200,8 +335,8 @@ def _build_function_section( function: SourceFunction, snapshot: SourceCoverageSnapshot, diagram_dir: Path, - page_number: int | None, - include_diagram: bool, + diagram_types: list[str], + page_numbers: list[int], ) -> FunctionCoverage: """Build the per-function coverage section rendered in the final HTML report.""" # Function-level metrics are derived from the source line span already parsed from AST. @@ -219,38 +354,54 @@ def _build_function_section( branch for branch in snapshot.missing_branches if _branch_in_function(branch, function) } - mermaid_path = "" - svg_path = "" - svg_data_uri = "" - if include_diagram: - mermaid_file, svg_file = _resolve_diagram_paths(diagram_dir, source_path, function.name) + diagrams: list[DiagramArtifact] = [] + for diagram_type, diagram_page_number in zip( + diagram_types, page_numbers, strict=True + ): + mermaid_file, svg_file = _resolve_diagram_paths( + diagram_dir, source_path, function.name, diagram_type + ) render_mermaid_svg(mermaid_file, svg_file) - mermaid_path = str(mermaid_file) - svg_path = str(svg_file) - svg_data_uri = _load_svg_data_uri(svg_file) + diagrams.append( + DiagramArtifact( + kind=diagram_type, + page_number=diagram_page_number, + mermaid_path=str(mermaid_file), + svg_path=str(svg_file), + svg_data_uri=_load_svg_data_uri(svg_file), + ) + ) + + primary_diagram = diagrams[0] if diagrams else None return FunctionCoverage( name=function.name, lineno=function.lineno, end_lineno=function.end_lineno, - page_number=page_number, + page_number=primary_diagram.page_number if primary_diagram else None, c0=_build_metric(covered_lines, len(relevant_lines)), c1=_build_metric(len(executed_branches), len(executed_branches | missing_branches)), missing_lines=missing_lines, missing_branches=[_format_branch(branch) for branch in sorted(missing_branches)], - diagram_included=include_diagram, - mermaid_path=mermaid_path, - svg_path=svg_path, - svg_data_uri=svg_data_uri, + diagram_included=bool(diagrams), + mermaid_path=primary_diagram.mermaid_path if primary_diagram else "", + svg_path=primary_diagram.svg_path if primary_diagram else "", + svg_data_uri=primary_diagram.svg_data_uri if primary_diagram else "", + diagrams=diagrams, ) -def _resolve_output_path(source_path: Path, base_html: Path, multiple_outputs: bool) -> Path: +def _resolve_output_path( + source_path: Path, + base_html: Path, + multiple_outputs: bool, + output_folder: str, +) -> Path: """Choose either the user path or a per-source report path for multi-file runs.""" if not multiple_outputs: return base_html - output_dir = base_html.parent / folder_name(source_path.stem) + output_dir = base_html.parent / output_folder output_dir.mkdir(parents=True, exist_ok=True) return output_dir / f"{folder_name(source_path.stem)}_coverage_report{base_html.suffix}" @@ -263,16 +414,20 @@ def _build_metric(covered: int, total: int) -> CoverageMetric: return CoverageMetric(covered=covered, total=total, percent=percent, missing=missing) -def _branch_in_function(branch: tuple[int, int], function: SourceFunction) -> bool: +def _branch_in_function( + branch: tuple[int, int] | tuple[int, int, str], + function: SourceFunction, +) -> bool: """Check whether a branch record starts inside the function line range.""" start_line = branch[0] return function.lineno <= start_line <= function.end_lineno -def _format_branch(branch: tuple[int, int]) -> str: +def _format_branch(branch: tuple[int, int] | tuple[int, int, str]) -> str: """Render a branch tuple into a readable report label.""" - start, end = branch - return f"{start} -> {'exit' if end < 0 else end}" + start, end = branch[:2] + identity = f" [{branch[2]}]" if len(branch) == 3 else "" + return f"{start} -> {'exit' if end < 0 else end}{identity}" def _load_svg_data_uri(svg_path: Path) -> str: @@ -283,14 +438,23 @@ def _load_svg_data_uri(svg_path: Path) -> str: return f"data:image/svg+xml;base64,{encoded}" -def _resolve_diagram_paths(diagram_dir: Path, source_path: Path, function_name: str) -> tuple[Path, Path]: +def _resolve_diagram_paths( + diagram_dir: Path, + source_path: Path, + function_name: str, + diagram_type: str = "flowchart", +) -> tuple[Path, Path]: """Resolve a Mermaid file path, allowing short-name fallback for class methods.""" - exact_paths = diagram_output_paths(diagram_dir, source_path, function_name) + exact_paths = diagram_output_paths( + diagram_dir, source_path, function_name, diagram_type + ) if exact_paths[0].exists() or "." not in function_name: return exact_paths short_name = function_name.rsplit(".", 1)[1] - short_paths = diagram_output_paths(diagram_dir, source_path, short_name) + short_paths = diagram_output_paths( + diagram_dir, source_path, short_name, diagram_type + ) if short_paths[0].exists(): return short_paths return exact_paths diff --git a/src/teaforge/discovery.py b/src/teaforge/discovery.py new file mode 100644 index 0000000..19c39c9 --- /dev/null +++ b/src/teaforge/discovery.py @@ -0,0 +1,56 @@ +"""Discover target-project files without descending into generated dependency trees.""" + +from __future__ import annotations + +import os +from collections.abc import Callable, Collection +from pathlib import Path + +DEFAULT_EXCLUDED_DIRECTORY_NAMES = frozenset( + { + ".git", + ".hg", + ".mypy_cache", + ".nox", + ".pytest_cache", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pycache__", + "build", + "dist", + "node_modules", + "venv", + } +) + + +def discover_files( + path: Path, + *, + is_candidate: Callable[[Path], bool], + excluded_directory_names: Collection[str] = DEFAULT_EXCLUDED_DIRECTORY_NAMES, +) -> list[Path]: + """Return deterministic candidates while pruning known generated directories.""" + if path.is_file(): + return [path] if is_candidate(path) else [] + if not path.is_dir(): + return [] + + excluded = frozenset(excluded_directory_names) + files: list[Path] = [] + for current_root, directory_names, file_names in os.walk( + path, + topdown=True, + followlinks=False, + ): + directory_names[:] = sorted( + name for name in directory_names if name not in excluded + ) + root = Path(current_root) + for file_name in sorted(file_names): + candidate = root / file_name + if is_candidate(candidate): + files.append(candidate) + return files diff --git a/src/teaforge/doctor.py b/src/teaforge/doctor.py new file mode 100644 index 0000000..36393e1 --- /dev/null +++ b/src/teaforge/doctor.py @@ -0,0 +1,354 @@ +"""Inspect whether TeaForge can execute a requested workflow before generation.""" + +from __future__ import annotations + +import importlib.util +import io +import json +import shutil +import sys +from contextlib import redirect_stderr, redirect_stdout +from dataclasses import asdict, dataclass +from importlib import resources +from pathlib import Path + +from teaforge import __version__ +from teaforge.jest.project import JestProject +from teaforge.pcl.backends import normalize_pcl_framework +from teaforge.process import ProcessTimeoutError, bounded_process_detail, run_process + + +@dataclass(slots=True, frozen=True) +class CapabilityCheck: + name: str + status: str + detail: str + hint: str = "" + + +@dataclass(slots=True) +class DoctorReport: + framework: str + checks: list[CapabilityCheck] + + @property + def ready(self) -> bool: + return all(check.status != "error" for check in self.checks) + + def to_dict(self) -> dict: + return { + "schema_version": 1, + "teaforge_version": __version__, + "framework": self.framework, + "ready": self.ready, + "checks": [asdict(check) for check in self.checks], + } + + def to_json(self) -> str: + return json.dumps(self.to_dict(), ensure_ascii=False, indent=2) + + +def inspect_capabilities( + *, + framework: str, + test_path: Path | None = None, + require_mermaid: bool = False, + require_pdf: bool = False, + timeout_seconds: int = 10, + python_executable: Path | None = None, +) -> DoctorReport: + """Return a non-mutating readiness report for one TeaForge workflow.""" + normalized = normalize_pcl_framework(framework) + if python_executable is not None and normalized != "pytest": + raise ValueError("--python-executable is supported only for the pytest workflow.") + checks = [ + CapabilityCheck( + name="teaforge", + status="ok", + detail=f"TeaForge {__version__} on Python {sys.version.split()[0]}", + ) + ] + checks.extend(_resource_checks()) + checks.append(_javascript_syntax_check()) + + if test_path is not None: + resolved_test_path = test_path.expanduser().resolve() + if resolved_test_path.exists(): + checks.append( + CapabilityCheck("test-path", "ok", str(resolved_test_path)) + ) + else: + checks.append( + CapabilityCheck( + "test-path", + "error", + f"Path does not exist: {resolved_test_path}", + "Move/copy the tests into place or pass the correct --path.", + ) + ) + else: + resolved_test_path = None + checks.append( + CapabilityCheck( + "test-path", + "warning", + "No target test path was supplied; project discovery was skipped.", + "Pass --path to verify a real project.", + ) + ) + + if normalized == "pytest": + checks.extend( + _python_test_checks( + python_executable=python_executable, + timeout_seconds=timeout_seconds, + ) + ) + elif normalized in {"jest", "angular"}: + checks.extend( + _jest_checks(resolved_test_path, timeout_seconds=timeout_seconds) + ) + + checks.append( + _command_check( + "mermaid", + "mmdc", + required=require_mermaid, + install_hint="Install Mermaid CLI: npm install -g @mermaid-js/mermaid-cli", + timeout_seconds=timeout_seconds, + ) + ) + checks.append(_pdf_check(required=require_pdf)) + return DoctorReport(framework=normalized, checks=checks) + + +def format_doctor_report(report: DoctorReport) -> str: + """Render a concise terminal view while preserving hints for agent callers.""" + labels = {"ok": "OK", "warning": "WARN", "error": "ERROR"} + lines = [f"TeaForge doctor ({report.framework})"] + for check in report.checks: + lines.append(f"[{labels[check.status]}] {check.name}: {check.detail}") + if check.hint: + lines.append(f" Hint: {check.hint}") + lines.append(f"Ready: {'yes' if report.ready else 'no'}") + return "\n".join(lines) + + +def _resource_checks() -> list[CapabilityCheck]: + required_resources = ( + ("pcl-template", "teaforge.templates", "pcl.html"), + ("coverage-template", "teaforge.templates", "coverage_report.html"), + ("jest-listener", "teaforge.jest.assets", "runtime-listener.cjs"), + ) + checks: list[CapabilityCheck] = [] + for name, package, filename in required_resources: + present = resources.files(package).joinpath(filename).is_file() + checks.append( + CapabilityCheck( + name, + "ok" if present else "error", + f"Packaged resource {'found' if present else 'missing'}: {filename}", + "Reinstall TeaForge from a valid wheel." if not present else "", + ) + ) + return checks + + +def _javascript_syntax_check() -> CapabilityCheck: + """Verify that the packaged JS/TS grammars can build structural evidence.""" + try: + from teaforge.javascript.syntax import extract_test_cases + + cases = extract_test_cases( + "test('probe', () => expect(1).toBe(1));", + suffix=".js", + source_name="doctor-probe.js", + ) + if len(cases) != 1 or cases[0].title != "probe": + raise RuntimeError("the parser returned unexpected probe evidence") + except Exception as exc: + return CapabilityCheck( + "javascript-syntax", + "error", + f"Tree-sitter JavaScript/TypeScript parser is unavailable: {exc}", + "Reinstall TeaForge from a valid wheel with its base dependencies.", + ) + return CapabilityCheck( + "javascript-syntax", + "ok", + "Tree-sitter JavaScript/TypeScript grammars parsed the probe source.", + ) + + +def _python_test_checks( + *, + python_executable: Path | None, + timeout_seconds: int, +) -> list[CapabilityCheck]: + selected_python = ( + python_executable or Path(sys.executable) + ).expanduser().absolute() + if not selected_python.is_file(): + return [ + CapabilityCheck( + "python-executable", + "error", + f"Python executable does not exist: {selected_python}", + "Pass the Python launcher inside the target project's virtual environment.", + ) + ] + checks: list[CapabilityCheck] = [] + for module_name, install_name in (("pytest", "pytest"), ("coverage", "coverage")): + try: + result = run_process( + [ + str(selected_python), + "-c", + ( + f"import {module_name}; " + f"print(getattr({module_name}, '__version__', 'available'))" + ), + ], + operation=f"{module_name} capability check", + timeout_seconds=timeout_seconds, + ) + present = result.returncode == 0 + version = result.stdout.strip() if present else "" + failure = bounded_process_detail(result.stderr, result.stdout) + except (OSError, ProcessTimeoutError) as exc: + present = False + version = "" + failure = str(exc) + checks.append( + CapabilityCheck( + module_name, + "ok" if present else "error", + ( + f"{module_name} {version or 'available'} via {selected_python}" + if present + else f"{module_name} is unavailable via {selected_python}: {failure}" + ), + ( + f"Install it in the target environment: {selected_python} -m pip install {install_name}" + if not present + else "" + ), + ) + ) + return checks + + +def _jest_checks( + test_path: Path | None, + *, + timeout_seconds: int, +) -> list[CapabilityCheck]: + checks = [ + _command_check( + "node", + "node", + required=True, + install_hint="Install a supported Node.js release.", + timeout_seconds=timeout_seconds, + ) + ] + if test_path is None or not test_path.exists(): + return checks + try: + project = JestProject.discover(test_path) + version_result = project.run( + ["--version"], + timeout_seconds=timeout_seconds, + operation="Jest version check", + ) + if version_result.returncode != 0: + detail = bounded_process_detail( + version_result.stderr, version_result.stdout + ) + raise RuntimeError(f"Jest --version failed: {detail}") + version = version_result.stdout.strip() or "unknown version" + checks.append( + CapabilityCheck( + "jest", + "ok", + f"{version} at {project.executable} (root: {project.root})", + ) + ) + checks.append( + CapabilityCheck( + "jest-tests", + "ok", + f"Discovered {len(project.test_files)} exact test file(s).", + ) + ) + except RuntimeError as exc: + checks.append( + CapabilityCheck( + "jest", + "error", + str(exc), + "Run the target project's package-manager install command first.", + ) + ) + return checks + + +def _command_check( + name: str, + executable: str, + *, + required: bool, + install_hint: str, + timeout_seconds: int, +) -> CapabilityCheck: + path = shutil.which(executable) + if path is None: + return CapabilityCheck( + name, + "error" if required else "warning", + f"Executable not found on PATH: {executable}", + install_hint, + ) + try: + result = run_process( + [path, "--version"], + operation=f"{name} version check", + timeout_seconds=timeout_seconds, + ) + except (OSError, ProcessTimeoutError) as exc: + return CapabilityCheck( + name, + "error" if required else "warning", + f"Could not execute {path}: {exc}", + install_hint, + ) + detail = (result.stdout or result.stderr).strip().splitlines() + version = detail[0] if detail else "version unavailable" + status = "ok" if result.returncode == 0 else ("error" if required else "warning") + return CapabilityCheck(name, status, f"{version} at {path}", install_hint if status != "ok" else "") + + +def _pdf_check(*, required: bool) -> CapabilityCheck: + if importlib.util.find_spec("weasyprint") is None: + return CapabilityCheck( + "pdf", + "error" if required else "warning", + "WeasyPrint is not installed.", + "Install TeaForge with the PDF extra: pip install 'teaforge[pdf]'", + ) + try: + diagnostics = io.StringIO() + with redirect_stdout(diagnostics), redirect_stderr(diagnostics): + from weasyprint import HTML + + payload = HTML(string="TeaForge").write_pdf() + if not payload.startswith(b"%PDF-"): + raise RuntimeError("WeasyPrint returned an invalid PDF payload.") + except (OSError, RuntimeError) as exc: + return CapabilityCheck( + "pdf", + "error" if required else "warning", + f"WeasyPrint is installed but not operational: {exc}", + "Install the required Pango/Cairo system libraries.", + ) + return CapabilityCheck("pdf", "ok", "WeasyPrint produced a valid PDF payload.") diff --git a/src/teaforge/javascript/__init__.py b/src/teaforge/javascript/__init__.py new file mode 100644 index 0000000..e65da5e --- /dev/null +++ b/src/teaforge/javascript/__init__.py @@ -0,0 +1,27 @@ +"""Structural JavaScript/TypeScript evidence used by framework adapters.""" + +from .syntax import ( + JavaScriptCall, + JavaScriptFunction, + JavaScriptImport, + JavaScriptSyntaxError, + JavaScriptTestCase, + extract_imports, + extract_test_cases, + parse_calls, + parse_source_functions, + source_defines_symbol, +) + +__all__ = [ + "JavaScriptCall", + "JavaScriptFunction", + "JavaScriptImport", + "JavaScriptSyntaxError", + "JavaScriptTestCase", + "extract_imports", + "extract_test_cases", + "parse_calls", + "parse_source_functions", + "source_defines_symbol", +] diff --git a/src/teaforge/javascript/syntax.py b/src/teaforge/javascript/syntax.py new file mode 100644 index 0000000..3d85ef3 --- /dev/null +++ b/src/teaforge/javascript/syntax.py @@ -0,0 +1,947 @@ +"""Build structural Static Evidence without executing or modifying target projects.""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import lru_cache +from typing import Iterator + +import tree_sitter_javascript +import tree_sitter_typescript +from tree_sitter import Language, Node, Parser, Tree + +JAVASCRIPT_SUFFIXES = {".js", ".jsx", ".mjs", ".cjs"} +TYPESCRIPT_SUFFIXES = {".ts", ".mts", ".cts"} +TSX_SUFFIXES = {".tsx"} +SUPPORTED_SUFFIXES = JAVASCRIPT_SUFFIXES | TYPESCRIPT_SUFFIXES | TSX_SUFFIXES +TEST_ROOTS = {"test", "it"} +DESCRIBE_ROOTS = {"describe"} + + +class JavaScriptSyntaxError(ValueError): + """Report a source location that could not be represented as safe Static Evidence.""" + + code = "javascript-syntax-error" + + +@dataclass(slots=True, frozen=True) +class JavaScriptImport: + module: str + local_name: str + imported_name: str + + +@dataclass(slots=True, frozen=True) +class JavaScriptTestCase: + title: str + full_name: str + body: str + context: dict[str, object] + start_byte: int + + +@dataclass(slots=True, frozen=True) +class JavaScriptCall: + callee: str + argument_text: str + arguments: tuple[str, ...] + start_byte: int + + +@dataclass(slots=True, frozen=True) +class JavaScriptFunction: + name: str + start_line: int + end_line: int + + +@dataclass(slots=True, frozen=True) +class _ParsedSource: + source: bytes + tree: Tree + suffix: str + source_name: str + + def text(self, node: Node) -> str: + return self.source[node.start_byte : node.end_byte].decode( + "utf-8", errors="replace" + ) + + +@dataclass(slots=True, frozen=True) +class _Callback: + node: Node + body: Node + parameter_names: tuple[str, ...] + + +@dataclass(slots=True, frozen=True) +class _Invocation: + title_node: Node + callback: _Callback + rows_node: Node | None + + +_UNRESOLVED = object() + + +def extract_imports( + source: str, + *, + suffix: str, + source_name: str = "", +) -> list[JavaScriptImport]: + """Return ESM and statically provable CommonJS bindings in source order.""" + parsed = _parse(source, suffix=suffix, source_name=source_name) + imports: list[JavaScriptImport] = [] + for statement in parsed.tree.root_node.named_children: + if statement.type == "import_statement": + imports.extend(_esm_imports(parsed, statement)) + elif statement.type in {"lexical_declaration", "variable_declaration"}: + imports.extend(_commonjs_imports(parsed, statement)) + return imports + + +def extract_test_cases( + source: str, + *, + suffix: str, + source_name: str = "", +) -> list[JavaScriptTestCase]: + """Return literal Jest test cases with lexical describe identity and each rows.""" + parsed = _parse(source, suffix=suffix, source_name=source_name) + cases: list[JavaScriptTestCase] = [] + _visit_test_scope( + parsed, + parsed.tree.root_node, + suite_titles=(), + inherited_context={}, + output=cases, + ) + cases.sort(key=lambda case: case.start_byte) + return cases + + +def parse_calls( + source: str, + *, + suffix: str, + source_name: str = "", +) -> list[JavaScriptCall]: + """Return structurally parsed call expressions in lexical source order.""" + parsed = _parse(source, suffix=suffix, source_name=source_name) + calls: list[JavaScriptCall] = [] + for node in _walk(parsed.tree.root_node): + if node.type != "call_expression": + continue + function = node.child_by_field_name("function") + arguments_node = node.child_by_field_name("arguments") + if function is None or arguments_node is None: + continue + callee = _callee_path(parsed, function) + if not callee: + continue + arguments = tuple( + parsed.text(argument) for argument in arguments_node.named_children + ) + argument_text = parsed.source[ + arguments_node.start_byte + 1 : arguments_node.end_byte - 1 + ].decode("utf-8", errors="replace") + calls.append( + JavaScriptCall( + callee=callee, + argument_text=argument_text, + arguments=arguments, + start_byte=node.start_byte, + ) + ) + calls.sort(key=lambda call: call.start_byte) + return calls + + +def parse_source_functions( + source: str, + *, + suffix: str, + source_name: str = "", +) -> list[JavaScriptFunction]: + """Return top-level functions and class methods from one structural index.""" + parsed = _parse(source, suffix=suffix, source_name=source_name) + functions: list[JavaScriptFunction] = [] + for statement in parsed.tree.root_node.named_children: + declaration = _unwrap_export(statement) + if declaration is None: + continue + if declaration.type in {"function_declaration", "generator_function_declaration"}: + name_node = declaration.child_by_field_name("name") + if name_node is not None: + functions.append(_function_fact(parsed, parsed.text(name_node), declaration)) + continue + if declaration.type in {"lexical_declaration", "variable_declaration"}: + for declarator in declaration.named_children: + if declarator.type != "variable_declarator": + continue + name_node = declarator.child_by_field_name("name") + value_node = declarator.child_by_field_name("value") + if ( + name_node is not None + and name_node.type == "identifier" + and value_node is not None + and value_node.type + in {"arrow_function", "function_expression", "generator_function"} + ): + functions.append( + _function_fact(parsed, parsed.text(name_node), declaration) + ) + continue + if declaration.type in {"class_declaration", "abstract_class_declaration"}: + functions.extend(_class_functions(parsed, declaration)) + continue + if declaration.type == "expression_statement": + assignment = next( + ( + child + for child in declaration.named_children + if child.type == "assignment_expression" + ), + None, + ) + if assignment is not None: + assigned = _assigned_function(parsed, assignment) + if assigned is not None: + functions.append(assigned) + unique = { + (function.name, function.start_line, function.end_line): function + for function in functions + } + return sorted(unique.values(), key=lambda item: (item.start_line, item.name)) + + +def source_defines_symbol( + source: str, + symbol_name: str, + *, + suffix: str, + source_name: str = "", +) -> bool: + """Prove that a module declares or explicitly exports a requested symbol.""" + parsed = _parse(source, suffix=suffix, source_name=source_name) + symbols: set[str] = set() + for statement in parsed.tree.root_node.named_children: + if statement.type == "export_statement": + prefix = parsed.text(statement).lstrip() + if prefix.startswith("export default"): + symbols.add("default") + symbols.update(_exported_names(parsed, statement)) + declaration = _unwrap_export(statement) + if declaration is not None and not prefix.startswith("export default"): + symbols.update(_declaration_names(parsed, declaration)) + continue + elif statement.type == "expression_statement": + symbols.update(_commonjs_export_names(parsed, statement)) + return symbol_name in symbols + + +def _parse(source: str, *, suffix: str, source_name: str) -> _ParsedSource: + normalized_suffix = _normalize_suffix(suffix) + source_bytes = source.encode("utf-8") + parser = Parser(_language(normalized_suffix)) + tree = parser.parse(source_bytes) + parsed = _ParsedSource( + source=source_bytes, + tree=tree, + suffix=normalized_suffix, + source_name=source_name, + ) + if tree.root_node.has_error: + problem = next( + ( + node + for node in _walk(tree.root_node) + if node.is_error or node.is_missing + ), + tree.root_node, + ) + line = problem.start_point.row + 1 + column = problem.start_point.column + 1 + excerpt = parsed.text(problem).strip().replace("\n", " ")[:120] + detail = f" near {excerpt!r}" if excerpt else "" + raise JavaScriptSyntaxError( + f"Could not parse JavaScript/TypeScript Static Evidence in " + f"{source_name}:{line}:{column}{detail}." + ) + return parsed + + +def _normalize_suffix(suffix: str) -> str: + normalized = suffix.lower() + if not normalized.startswith("."): + normalized = f".{normalized}" + if normalized not in SUPPORTED_SUFFIXES: + raise ValueError( + f"Unsupported JavaScript/TypeScript suffix: {suffix}. " + f"Supported values: {', '.join(sorted(SUPPORTED_SUFFIXES))}" + ) + return normalized + + +@lru_cache(maxsize=None) +def _language(suffix: str) -> Language: + if suffix in JAVASCRIPT_SUFFIXES: + return Language(tree_sitter_javascript.language()) + if suffix in TSX_SUFFIXES: + return Language(tree_sitter_typescript.language_tsx()) + return Language(tree_sitter_typescript.language_typescript()) + + +def _walk(node: Node) -> Iterator[Node]: + yield node + for child in node.named_children: + yield from _walk(child) + + +def _esm_imports(parsed: _ParsedSource, statement: Node) -> list[JavaScriptImport]: + source_node = statement.child_by_field_name("source") + if source_node is None: + return [] + module = _static_string(parsed, source_node, {}) + if module is None: + return [] + clause = next( + (child for child in statement.named_children if child.type == "import_clause"), + None, + ) + if clause is None: + return [] + result: list[JavaScriptImport] = [] + for child in clause.named_children: + if child.type == "identifier": + result.append(JavaScriptImport(module, parsed.text(child), "default")) + elif child.type == "namespace_import": + identifier = next( + (item for item in child.named_children if item.type == "identifier"), + None, + ) + if identifier is not None: + result.append(JavaScriptImport(module, parsed.text(identifier), "*")) + elif child.type == "named_imports": + for specifier in child.named_children: + if specifier.type != "import_specifier": + continue + name = specifier.child_by_field_name("name") + alias = specifier.child_by_field_name("alias") + if name is not None: + result.append( + JavaScriptImport( + module, + parsed.text(alias or name), + parsed.text(name), + ) + ) + return result + + +def _commonjs_imports( + parsed: _ParsedSource, + declaration: Node, +) -> list[JavaScriptImport]: + result: list[JavaScriptImport] = [] + for declarator in declaration.named_children: + if declarator.type != "variable_declarator": + continue + name = declarator.child_by_field_name("name") + value = declarator.child_by_field_name("value") + if name is None or value is None: + continue + require = _require_value(parsed, value) + if require is None: + continue + module, member = require + if name.type == "identifier": + result.append( + JavaScriptImport(module, parsed.text(name), member or "*") + ) + elif name.type == "object_pattern": + for binding in name.named_children: + if binding.type == "pair_pattern": + key = binding.child_by_field_name("key") + local = binding.child_by_field_name("value") + if key is not None and local is not None: + result.append( + JavaScriptImport( + module, + parsed.text(local), + parsed.text(key), + ) + ) + elif binding.type == "shorthand_property_identifier_pattern": + binding_name = parsed.text(binding) + result.append( + JavaScriptImport(module, binding_name, binding_name) + ) + return result + + +def _require_value(parsed: _ParsedSource, node: Node) -> tuple[str, str | None] | None: + member: str | None = None + call = node + if node.type == "member_expression": + call = node.child_by_field_name("object") + property_node = node.child_by_field_name("property") + if property_node is not None: + member = parsed.text(property_node) + if call is None or call.type != "call_expression": + return None + function = call.child_by_field_name("function") + arguments = call.child_by_field_name("arguments") + if ( + function is None + or parsed.text(function) != "require" + or arguments is None + or len(arguments.named_children) != 1 + ): + return None + module = _static_string(parsed, arguments.named_children[0], {}) + return (module, member) if module is not None else None + + +def _visit_test_scope( + parsed: _ParsedSource, + node: Node, + *, + suite_titles: tuple[str, ...], + inherited_context: dict[str, object], + output: list[JavaScriptTestCase], +) -> None: + if node.type == "call_expression": + suite_invocation = _decode_invocation(parsed, node, DESCRIBE_ROOTS) + if suite_invocation is not None: + for rendered_title, row_context in _expand_invocation( + parsed, suite_invocation, inherited_context + ): + merged_context = {**inherited_context, **row_context} + _visit_test_scope( + parsed, + suite_invocation.callback.body, + suite_titles=(*suite_titles, rendered_title), + inherited_context=merged_context, + output=output, + ) + return + + test_invocation = _decode_invocation(parsed, node, TEST_ROOTS) + if test_invocation is not None: + for rendered_title, row_context in _expand_invocation( + parsed, test_invocation, inherited_context + ): + merged_context = {**inherited_context, **row_context} + output.append( + JavaScriptTestCase( + title=rendered_title, + full_name=" ".join((*suite_titles, rendered_title)).strip(), + body=_callback_body_text(parsed, test_invocation.callback.body), + context=merged_context, + start_byte=node.start_byte, + ) + ) + return + + for child in node.named_children: + _visit_test_scope( + parsed, + child, + suite_titles=suite_titles, + inherited_context=inherited_context, + output=output, + ) + + +def _decode_invocation( + parsed: _ParsedSource, + call: Node, + roots: set[str], +) -> _Invocation | None: + function = call.child_by_field_name("function") + arguments = call.child_by_field_name("arguments") + if function is None or arguments is None: + return None + rows_node: Node | None = None + callee = _callee_path(parsed, function) + if callee is None and function.type == "call_expression": + each_function = function.child_by_field_name("function") + each_arguments = function.child_by_field_name("arguments") + each_callee = ( + _callee_path(parsed, each_function) if each_function is not None else None + ) + if ( + each_callee + and _root_name(each_callee) in roots + and each_callee.split(".")[-1] == "each" + and each_arguments is not None + and each_arguments.named_children + ): + callee = each_callee + rows_node = each_arguments.named_children[0] + if callee is None or _root_name(callee) not in roots: + return None + if callee.split(".")[-1] == "each" and rows_node is None: + return None + invocation_arguments = arguments.named_children + if len(invocation_arguments) < 2: + return None + callback = _callback(parsed, invocation_arguments[1]) + if callback is None: + return None + return _Invocation( + title_node=invocation_arguments[0], + callback=callback, + rows_node=rows_node, + ) + + +def _callback(parsed: _ParsedSource, node: Node) -> _Callback | None: + if node.type not in {"arrow_function", "function_expression", "function"}: + return None + body = node.child_by_field_name("body") + if body is None: + return None + parameters = node.child_by_field_name("parameters") + parameter_names = tuple(_pattern_names(parsed, parameters)) if parameters else () + return _Callback(node=node, body=body, parameter_names=parameter_names) + + +def _pattern_names(parsed: _ParsedSource, node: Node) -> list[str]: + if node.type in { + "identifier", + "shorthand_property_identifier_pattern", + }: + return [parsed.text(node)] + if node.type in {"required_parameter", "optional_parameter"}: + pattern = node.child_by_field_name("pattern") + return _pattern_names(parsed, pattern) if pattern is not None else [] + if node.type == "assignment_pattern": + left = node.child_by_field_name("left") + return _pattern_names(parsed, left) if left is not None else [] + names: list[str] = [] + for child in node.named_children: + if child.type in {"type_annotation", "predefined_type", "type_identifier"}: + continue + names.extend(_pattern_names(parsed, child)) + return names + + +def _expand_invocation( + parsed: _ParsedSource, + invocation: _Invocation, + inherited_context: dict[str, object], +) -> list[tuple[str, dict[str, object]]]: + if invocation.rows_node is None: + title = _static_string(parsed, invocation.title_node, inherited_context) + return [(title, {})] if title is not None else [] + + rows = _literal_value(parsed, invocation.rows_node, inherited_context) + if not isinstance(rows, list): + return [] + expanded: list[tuple[str, dict[str, object]]] = [] + for index, row in enumerate(rows, start=1): + context = _row_context(invocation.callback.parameter_names, row) + combined = {**inherited_context, **context} + title = _static_string(parsed, invocation.title_node, combined) + if title is None: + continue + expanded.append((_render_each_title(title, row, context, index), context)) + return expanded + + +def _row_context(parameter_names: tuple[str, ...], row: object) -> dict[str, object]: + if isinstance(row, dict): + return dict(row) + if isinstance(row, list): + return { + name: row[index] + for index, name in enumerate(parameter_names) + if index < len(row) + } + if len(parameter_names) == 1: + return {parameter_names[0]: row} + return {} + + +def _render_each_title( + title: str, + row: object, + context: dict[str, object], + index: int, +) -> str: + rendered = title + values = row if isinstance(row, list) else list(context.values()) + pending = [_display_value(value) for value in values] + for token in ("%s", "%d", "%i", "%f", "%p", "%j", "%o"): + while token in rendered and pending: + rendered = rendered.replace(token, pending.pop(0), 1) + for name, value in context.items(): + rendered = rendered.replace(f"${name}", _display_value(value)) + if rendered == title: + rendered = f"{title} case {index:02d}" + return rendered + + +def _static_string( + parsed: _ParsedSource, + node: Node, + context: dict[str, object], +) -> str | None: + if node.type == "string": + raw = parsed.text(node) + return _decode_quoted_string(raw) + if node.type == "template_string": + parts: list[str] = [] + for child in node.named_children: + if child.type == "string_fragment": + parts.append(parsed.text(child)) + elif child.type == "escape_sequence": + parts.append(_decode_escape(parsed.text(child))) + elif child.type == "template_substitution": + expression = next(iter(child.named_children), None) + value = ( + _literal_value(parsed, expression, context) + if expression is not None + else _UNRESOLVED + ) + if value is _UNRESOLVED: + return None + parts.append(_display_value(value)) + return "".join(parts) + return None + + +def _literal_value( + parsed: _ParsedSource, + node: Node, + context: dict[str, object], +) -> object: + if node.type in {"string", "template_string"}: + value = _static_string(parsed, node, context) + return value if value is not None else _UNRESOLVED + if node.type == "number": + raw = parsed.text(node).replace("_", "") + try: + return float(raw) if any(char in raw for char in ".eE") else int(raw, 0) + except ValueError: + return _UNRESOLVED + if node.type in {"true", "false"}: + return node.type == "true" + if node.type == "null": + return "null" + if node.type == "undefined": + return "undefined" + if node.type == "identifier": + return context.get(parsed.text(node), _UNRESOLVED) + if node.type == "array": + values: list[object] = [] + for child in node.named_children: + value = _literal_value(parsed, child, context) + if value is _UNRESOLVED: + return _UNRESOLVED + values.append(value) + return values + if node.type == "object": + result: dict[str, object] = {} + for child in node.named_children: + if child.type == "pair": + key_node = child.child_by_field_name("key") + value_node = child.child_by_field_name("value") + if key_node is None or value_node is None: + return _UNRESOLVED + key = _property_name(parsed, key_node, context) + value = _literal_value(parsed, value_node, context) + if key is None or value is _UNRESOLVED: + return _UNRESOLVED + result[key] = value + elif child.type == "shorthand_property_identifier": + key = parsed.text(child) + value = context.get(key, _UNRESOLVED) + if value is _UNRESOLVED: + return _UNRESOLVED + result[key] = value + else: + return _UNRESOLVED + return result + if node.type in { + "parenthesized_expression", + "as_expression", + "satisfies_expression", + "type_assertion", + }: + expression = node.child_by_field_name("expression") or next( + ( + child + for child in node.named_children + if child.type not in {"type_annotation", "type_identifier"} + ), + None, + ) + return ( + _literal_value(parsed, expression, context) + if expression is not None + else _UNRESOLVED + ) + if node.type == "unary_expression": + argument = node.child_by_field_name("argument") + if argument is None: + return _UNRESOLVED + value = _literal_value(parsed, argument, context) + operator = parsed.text(node)[: argument.start_byte - node.start_byte].strip() + if isinstance(value, (int, float)) and operator in {"+", "-"}: + return value if operator == "+" else -value + if node.type == "member_expression": + object_node = node.child_by_field_name("object") + property_node = node.child_by_field_name("property") + if object_node is not None and property_node is not None: + owner = _literal_value(parsed, object_node, context) + key = parsed.text(property_node) + if isinstance(owner, dict) and key in owner: + return owner[key] + return _UNRESOLVED + + +def _property_name( + parsed: _ParsedSource, + node: Node, + context: dict[str, object], +) -> str | None: + if node.type in {"property_identifier", "identifier", "number"}: + return parsed.text(node) + value = _static_string(parsed, node, context) + return value + + +def _decode_quoted_string(raw: str) -> str: + if len(raw) < 2: + return raw + inner = raw[1:-1] + result: list[str] = [] + cursor = 0 + while cursor < len(inner): + if inner[cursor] == "\\" and cursor + 1 < len(inner): + escape = inner[cursor : cursor + 2] + result.append(_decode_escape(escape)) + cursor += 2 + else: + result.append(inner[cursor]) + cursor += 1 + return "".join(result) + + +def _decode_escape(raw: str) -> str: + escapes = { + "\\n": "\n", + "\\r": "\r", + "\\t": "\t", + "\\b": "\b", + "\\f": "\f", + "\\v": "\v", + "\\0": "\0", + "\\\\": "\\", + "\\\"": '"', + "\\'": "'", + "\\`": "`", + } + return escapes.get(raw, raw[1:] if raw.startswith("\\") else raw) + + +def _display_value(value: object) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, dict): + return "{" + ", ".join( + f"{key}: {_display_value(inner)}" for key, inner in value.items() + ) + "}" + if isinstance(value, list): + return "[" + ", ".join(_display_value(inner) for inner in value) + "]" + return str(value) + + +def _callback_body_text(parsed: _ParsedSource, body: Node) -> str: + if body.type == "statement_block": + return parsed.source[body.start_byte + 1 : body.end_byte - 1].decode( + "utf-8", errors="replace" + ).strip() + return parsed.text(body).strip() + + +def _callee_path(parsed: _ParsedSource, node: Node) -> str | None: + if node.type in {"identifier", "property_identifier"}: + return parsed.text(node) + if node.type in {"member_expression", "optional_chain"}: + owner = node.child_by_field_name("object") + property_node = node.child_by_field_name("property") + if owner is None or property_node is None: + return None + owner_path = _callee_path(parsed, owner) + return ( + f"{owner_path}.{parsed.text(property_node)}" if owner_path else None + ) + return None + + +def _root_name(callee: str) -> str: + return callee.split(".", 1)[0] + + +def _unwrap_export(statement: Node) -> Node | None: + if statement.type != "export_statement": + return statement + return statement.child_by_field_name("declaration") or next( + ( + child + for child in statement.named_children + if child.type + in { + "function_declaration", + "generator_function_declaration", + "class_declaration", + "abstract_class_declaration", + "lexical_declaration", + "variable_declaration", + "expression_statement", + } + ), + None, + ) + + +def _function_fact( + parsed: _ParsedSource, + name: str, + node: Node, +) -> JavaScriptFunction: + return JavaScriptFunction( + name=name, + start_line=node.start_point.row + 1, + end_line=node.end_point.row + 1, + ) + + +def _class_functions( + parsed: _ParsedSource, + declaration: Node, +) -> list[JavaScriptFunction]: + name_node = declaration.child_by_field_name("name") + body = declaration.child_by_field_name("body") + if name_node is None or body is None: + return [] + class_name = parsed.text(name_node) + functions: list[JavaScriptFunction] = [] + for member in body.named_children: + if member.type == "method_definition": + method_name = member.child_by_field_name("name") + if method_name is None or parsed.text(method_name) == "constructor": + continue + functions.append( + _function_fact( + parsed, + f"{class_name}.{parsed.text(method_name)}", + member, + ) + ) + elif member.type == "public_field_definition": + field_name = member.child_by_field_name("name") + value = member.child_by_field_name("value") + if ( + field_name is not None + and value is not None + and value.type in {"arrow_function", "function_expression"} + ): + functions.append( + _function_fact( + parsed, + f"{class_name}.{parsed.text(field_name)}", + member, + ) + ) + return functions + + +def _assigned_function( + parsed: _ParsedSource, + assignment: Node, +) -> JavaScriptFunction | None: + left = assignment.child_by_field_name("left") + right = assignment.child_by_field_name("right") + if ( + left is None + or right is None + or right.type not in {"function_expression", "arrow_function"} + ): + return None + name = _member_property(parsed, left) + return _function_fact(parsed, name, assignment) if name else None + + +def _member_property(parsed: _ParsedSource, node: Node) -> str | None: + if node.type != "member_expression": + return None + property_node = node.child_by_field_name("property") + return parsed.text(property_node) if property_node is not None else None + + +def _exported_names(parsed: _ParsedSource, statement: Node) -> set[str]: + names: set[str] = set() + for node in _walk(statement): + if node.type != "export_specifier": + continue + name = node.child_by_field_name("name") + alias = node.child_by_field_name("alias") + if name is not None: + names.add(parsed.text(alias or name)) + return names + + +def _declaration_names(parsed: _ParsedSource, declaration: Node) -> set[str]: + names: set[str] = set() + if declaration.type in { + "function_declaration", + "generator_function_declaration", + "class_declaration", + "abstract_class_declaration", + }: + name_node = declaration.child_by_field_name("name") + if name_node is not None: + names.add(parsed.text(name_node)) + elif declaration.type in {"lexical_declaration", "variable_declaration"}: + for declarator in declaration.named_children: + if declarator.type != "variable_declarator": + continue + name_node = declarator.child_by_field_name("name") + if name_node is not None and name_node.type == "identifier": + names.add(parsed.text(name_node)) + return names + + +def _commonjs_export_names(parsed: _ParsedSource, statement: Node) -> set[str]: + names: set[str] = set() + for node in _walk(statement): + if node.type != "assignment_expression": + continue + left = node.child_by_field_name("left") + right = node.child_by_field_name("right") + if left is None: + continue + left_text = parsed.text(left) + if left_text in {"module.exports", "exports"} and right is not None: + if right.type == "object": + for child in right.named_children: + if child.type == "shorthand_property_identifier": + names.add(parsed.text(child)) + elif child.type == "pair": + key = child.child_by_field_name("key") + if key is not None: + rendered = _property_name(parsed, key, {}) + if rendered: + names.add(rendered) + elif left_text.startswith(("exports.", "module.exports.")): + property_name = _member_property(parsed, left) + if property_name: + names.add(property_name) + return names diff --git a/src/teaforge/jest/assets/__init__.py b/src/teaforge/jest/assets/__init__.py new file mode 100644 index 0000000..bc9b2cb --- /dev/null +++ b/src/teaforge/jest/assets/__init__.py @@ -0,0 +1 @@ +"""Runtime assets injected into Jest projects by TeaForge.""" diff --git a/src/teaforge/jest/assets/runtime-listener.cjs b/src/teaforge/jest/assets/runtime-listener.cjs new file mode 100644 index 0000000..b6535db --- /dev/null +++ b/src/teaforge/jest/assets/runtime-listener.cjs @@ -0,0 +1,211 @@ +"use strict"; + +const fs = require("fs"); + +const evidencePath = process.env.TEAFORGE_JEST_EVIDENCE_PATH; +const originalExpect = global.expect; +const MAX_VALUE_LENGTH = 4096; +const DEFAULT_MAX_ASSERTIONS = 5000; +const DEFAULT_MAX_EVIDENCE_BYTES = 5 * 1024 * 1024; +const WARNING_RESERVE_BYTES = 512; + +const positiveInteger = (value, fallback) => { + const parsed = Number.parseInt(value || "", 10); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; +}; + +const maxAssertions = positiveInteger( + process.env.TEAFORGE_JEST_MAX_ASSERTIONS, + DEFAULT_MAX_ASSERTIONS, +); +const maxEvidenceBytes = positiveInteger( + process.env.TEAFORGE_JEST_MAX_EVIDENCE_BYTES, + DEFAULT_MAX_EVIDENCE_BYTES, +); +const sensitiveKey = /(?:password|passwd|secret|token|authorization|cookie|api[_-]?key|session)/i; +const secretTextPatterns = [ + /\bBearer\s+[A-Za-z0-9._~+\/-]+=*/gi, + /\bsk-[A-Za-z0-9_-]{16,}\b/g, + /\bAKIA[A-Z0-9]{16}\b/g, + /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, +]; + +if (evidencePath && typeof originalExpect === "function") { + let assertionCount = 0; + let limitWarningWritten = false; + + const redactText = (value) => { + let redacted = value; + for (const pattern of secretTextPatterns) { + redacted = redacted.replace(pattern, "[REDACTED]"); + } + return redacted; + }; + + const render = (value) => { + const seen = new WeakSet(); + let rendered; + try { + rendered = JSON.stringify(value, (key, nested) => { + if (key && sensitiveKey.test(key)) return "[REDACTED]"; + if (typeof nested === "bigint") return `${nested.toString()}n`; + if (typeof nested === "function") return `[Function ${nested.name || "anonymous"}]`; + if (typeof nested === "symbol") return nested.toString(); + if (typeof nested === "string") return redactText(nested); + if (nested instanceof Error) { + return { name: nested.name, message: nested.message }; + } + if (nested && typeof nested === "object") { + if (seen.has(nested)) return "[Circular]"; + seen.add(nested); + } + return nested; + }); + } catch (_error) { + rendered = redactText(String(value)); + } + if (rendered === undefined) rendered = redactText(String(value)); + if (rendered.length > MAX_VALUE_LENGTH) { + return `${rendered.slice(0, MAX_VALUE_LENGTH)}…[truncated]`; + } + return rendered; + }; + + const currentEvidenceBytes = () => { + try { + return fs.statSync(evidencePath).size; + } catch (error) { + if (error && error.code === "ENOENT") return 0; + throw error; + } + }; + + const appendPayload = (payload, reserveBytes = 0) => { + const line = `${JSON.stringify(payload)}\n`; + const lineBytes = Buffer.byteLength(line, "utf8"); + if (currentEvidenceBytes() + lineBytes + reserveBytes > maxEvidenceBytes) { + return false; + } + fs.appendFileSync(evidencePath, line, "utf8"); + return true; + }; + + const writeLimitWarning = (message) => { + if (limitWarningWritten) return; + limitWarningWritten = true; + appendPayload({ + schema_version: 1, + kind: "warning", + code: "evidence-limit-reached", + message, + }); + }; + + const writeEvidence = ({ actual, expected, matcher, modifiers, passed, error }) => { + if (assertionCount >= maxAssertions) { + writeLimitWarning( + `Jest evidence was capped at ${maxAssertions} assertion records.`, + ); + return; + } + const state = typeof originalExpect.getState === "function" ? originalExpect.getState() : {}; + const payload = { + schema_version: 1, + kind: "assertion", + test_name: state.currentTestName || "", + test_path: state.testPath || "", + matcher, + expected: expected.map(render), + actual: render(actual), + passed, + negated: modifiers.includes("not"), + promise_mode: modifiers.includes("resolves") + ? "resolves" + : modifiers.includes("rejects") + ? "rejects" + : "", + error: error ? `${error.name || "Error"}: matcher failed; inspect expected and actual evidence.` : "", + }; + if (!appendPayload(payload, WARNING_RESERVE_BYTES)) { + writeLimitWarning( + `Jest evidence reached its ${maxEvidenceBytes}-byte limit and was truncated.`, + ); + return; + } + assertionCount += 1; + }; + + const observedPromiseValue = async (actual, modifiers) => { + if (modifiers.includes("resolves")) return Promise.resolve(actual); + if (modifiers.includes("rejects")) { + try { + return await Promise.resolve(actual); + } catch (error) { + return error; + } + } + return actual; + }; + + const wrapExpectation = (expectation, actual, modifiers = []) => new Proxy(expectation, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (typeof value === "function") { + const matcher = String(property); + return (...expected) => { + let observedActual = actual; + let matcherTarget = target; + let matcherFunction = value; + if (typeof actual === "function" && matcher.startsWith("toThrow")) { + const observedFunction = (...args) => { + try { + const result = actual(...args); + observedActual = result; + return result; + } catch (error) { + observedActual = error; + throw error; + } + }; + matcherTarget = originalExpect(observedFunction); + for (const modifier of modifiers) matcherTarget = matcherTarget[modifier]; + matcherFunction = matcherTarget[property]; + } + try { + const result = Reflect.apply(matcherFunction, matcherTarget, expected); + if (result && typeof result.then === "function") { + return Promise.resolve(result).then( + async (resolved) => { + const observed = await observedPromiseValue(observedActual, modifiers); + writeEvidence({ actual: observed, expected, matcher, modifiers, passed: true }); + return resolved; + }, + async (error) => { + const observed = await observedPromiseValue(observedActual, modifiers); + writeEvidence({ actual: observed, expected, matcher, modifiers, passed: false, error }); + throw error; + }, + ); + } + writeEvidence({ actual: observedActual, expected, matcher, modifiers, passed: true }); + return result; + } catch (error) { + writeEvidence({ actual: observedActual, expected, matcher, modifiers, passed: false, error }); + throw error; + } + }; + } + if (value && typeof value === "object") { + return wrapExpectation(value, actual, [...modifiers, String(property)]); + } + return value; + }, + }); + + global.expect = new Proxy(originalExpect, { + apply(target, thisArg, args) { + const expectation = Reflect.apply(target, thisArg, args); + return wrapExpectation(expectation, args[0]); + }, + }); +} diff --git a/src/teaforge/jest/coverage.py b/src/teaforge/jest/coverage.py index 6f13ca3..be01a41 100644 --- a/src/teaforge/jest/coverage.py +++ b/src/teaforge/jest/coverage.py @@ -3,11 +3,13 @@ from __future__ import annotations import json -import subprocess from pathlib import Path from typing import TYPE_CHECKING +from teaforge.javascript.syntax import parse_source_functions as parse_javascript_source_functions from teaforge.jest.parser import parse_jest_documents +from teaforge.jest.project import JestProject +from teaforge.process import bounded_process_detail if TYPE_CHECKING: from teaforge.coverage.analyzer import SourceCoverageSnapshot, SourceFunction @@ -38,20 +40,44 @@ def parse_jest_source_functions(source_path: Path) -> list[SourceFunction]: from teaforge.coverage.analyzer import SourceFunction source = source_path.read_text(encoding="utf-8") - functions: list[SourceFunction] = [] - functions.extend(_parse_top_level_functions(source)) - functions.extend(_parse_arrow_functions(source)) - functions.extend(_parse_class_methods(source)) - functions.sort(key=lambda function: (function.lineno, function.name)) - return functions + return [ + SourceFunction( + name=function.name, + lineno=function.start_line, + end_lineno=function.end_line, + ) + for function in parse_javascript_source_functions( + source, + suffix=source_path.suffix, + source_name=str(source_path), + ) + ] def analyze_jest_coverage( test_path: Path, source_paths: list[Path], + *, + timeout_seconds: int = 120, ) -> dict[Path, SourceCoverageSnapshot]: """Run Jest with Istanbul JSON coverage output and map it to resolved sources.""" - resolved_test_path = test_path.resolve() + return _analyze_jest_coverage( + test_path, + source_paths, + timeout_seconds=timeout_seconds, + operation="Jest coverage collection", + ) + + +def _analyze_jest_coverage( + test_path: Path, + source_paths: list[Path], + *, + timeout_seconds: int, + operation: str, +) -> dict[Path, SourceCoverageSnapshot]: + """Execute the project-local Jest once and parse its Istanbul payload.""" + project = JestProject.discover(test_path) resolved_sources = [path.resolve() for path in source_paths] import tempfile @@ -62,31 +88,24 @@ def analyze_jest_coverage( coverage_dir.mkdir(parents=True, exist_ok=True) coverage_file = coverage_dir / "coverage-final.json" - try: - run_result = subprocess.run( - [ - "npx", - "jest", - "--coverage", - "--coverageReporters=json", - "--coverageDirectory", - str(coverage_dir), - "--runInBand", - str(resolved_test_path), - ], - capture_output=True, - text=True, - check=False, - ) - except FileNotFoundError as exc: - raise RuntimeError( - "Jest coverage execution requires `npx`. Install Node.js and Jest before running coverage reports." - ) from exc + run_result = project.run( + [ + "--coverage", + "--coverageReporters=json", + "--coverageDirectory", + str(coverage_dir), + "--runInBand", + "--runTestsByPath", + *[str(path) for path in project.test_files], + ], + timeout_seconds=timeout_seconds, + operation=operation, + ) if run_result.returncode != 0: - detail = (run_result.stderr or run_result.stdout).strip() + detail = bounded_process_detail(run_result.stderr, run_result.stdout) raise RuntimeError( - "Jest failed while collecting coverage data. " + f"{operation} failed. " f"Exit code: {run_result.returncode}. {detail}" ) @@ -158,17 +177,17 @@ def _collect_line_sets( def _collect_branch_sets( branches: dict, branch_hits: dict, -) -> tuple[set[tuple[int, int]], set[tuple[int, int]]]: +) -> tuple[set[tuple[int, int, str]], set[tuple[int, int, str]]]: """Collect covered and missing branch edges from Istanbul branch maps.""" - executed_branches: set[tuple[int, int]] = set() - missing_branches: set[tuple[int, int]] = set() + executed_branches: set[tuple[int, int, str]] = set() + missing_branches: set[tuple[int, int, str]] = set() for branch_id, branch in branches.items(): branch_line = int(branch.get("line") or branch.get("loc", {}).get("start", {}).get("line", 0)) locations = branch.get("locations", []) hits = branch_hits.get(branch_id, []) for index, location in enumerate(locations): destination = int(location.get("start", {}).get("line", branch_line)) - edge = (branch_line, destination) + edge = (branch_line, destination, f"{branch_id}:{index}") if index < len(hits) and int(hits[index]) > 0: executed_branches.add(edge) else: @@ -178,147 +197,10 @@ def _collect_branch_sets( return executed_branches, missing_branches -def _parse_top_level_functions(source: str) -> list[SourceFunction]: - """Parse top-level function declarations from JS/TS source text.""" - from teaforge.coverage.analyzer import SourceFunction - - functions: list[SourceFunction] = [] - import re - - pattern = re.compile(r"(?:export\s+)?(?:async\s+)?function\s+(?P[A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{") - for match in pattern.finditer(source): - open_brace = match.end() - 1 - close_brace = _find_matching_brace(source, open_brace) - if close_brace == -1: - continue - functions.append( - SourceFunction( - name=match.group("name"), - lineno=_line_number_at(source, match.start()), - end_lineno=_line_number_at(source, close_brace), - ) - ) - return functions - - -def _parse_arrow_functions(source: str) -> list[SourceFunction]: - """Parse top-level arrow-function variable declarations from JS/TS source text.""" - from teaforge.coverage.analyzer import SourceFunction - - functions: list[SourceFunction] = [] - import re - - pattern = re.compile( - r"(?:export\s+)?(?:const|let|var)\s+(?P[A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{" - ) - for match in pattern.finditer(source): - open_brace = match.end() - 1 - close_brace = _find_matching_brace(source, open_brace) - if close_brace == -1: - continue - functions.append( - SourceFunction( - name=match.group("name"), - lineno=_line_number_at(source, match.start()), - end_lineno=_line_number_at(source, close_brace), - ) - ) - return functions - - -def _parse_class_methods(source: str) -> list[SourceFunction]: - """Parse class method declarations from JS/TS source text.""" - from teaforge.coverage.analyzer import SourceFunction - - methods: list[SourceFunction] = [] - import re - - class_pattern = re.compile(r"(?:export\s+)?class\s+(?P[A-Za-z_$][\w$]*)[^\{]*\{") - method_pattern = re.compile(r"^\s*(?:async\s+)?(?P[A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{", re.MULTILINE) - for class_match in class_pattern.finditer(source): - class_name = class_match.group("name") - class_open = class_match.end() - 1 - class_close = _find_matching_brace(source, class_open) - if class_close == -1: - continue - body = source[class_open + 1 : class_close] - body_offset = class_open + 1 - for method_match in method_pattern.finditer(body): - method_name = method_match.group("name") - if method_name == "constructor": - continue - method_start = body_offset + method_match.start() - method_open = body_offset + method_match.end() - 1 - method_close = _find_matching_brace(source, method_open) - if method_close == -1 or method_close > class_close: - continue - methods.append( - SourceFunction( - name=f"{class_name}.{method_name}", - lineno=_line_number_at(source, method_start), - end_lineno=_line_number_at(source, method_close), - ) - ) - return methods - - -def _find_matching_brace(source: str, open_index: int) -> int: - """Find the matching closing brace in a JS/TS source block.""" - depth = 0 - cursor = open_index - while cursor < len(source): - char = source[cursor] - if char in {'"', "'", "`"}: - cursor = _skip_string_literal(source, cursor) - if cursor == -1: - return -1 - continue - if source.startswith("//", cursor): - newline = source.find("\n", cursor) - if newline == -1: - return -1 - cursor = newline + 1 - continue - if source.startswith("/*", cursor): - end_comment = source.find("*/", cursor + 2) - if end_comment == -1: - return -1 - cursor = end_comment + 2 - continue - if char == "{": - depth += 1 - elif char == "}": - depth -= 1 - if depth == 0: - return cursor - cursor += 1 - return -1 - - -def _skip_string_literal(source: str, cursor: int) -> int: - """Skip past one string literal, returning the next cursor position.""" - quote = source[cursor] - cursor += 1 - while cursor < len(source): - char = source[cursor] - if char == "\\": - cursor += 2 - continue - if char == quote: - return cursor + 1 - cursor += 1 - return -1 - - -def _line_number_at(source: str, index: int) -> int: - """Translate a character offset into a 1-based source line number.""" - return source.count("\n", 0, index) + 1 - - def _document_uses_test_file_as_source(document) -> bool: """Detect parser fallbacks where the test file could not be resolved to production code.""" return ( document.source_path == document.test_source_path and document.file == document.test_file and document.method == document.test_method - ) \ No newline at end of file + ) diff --git a/src/teaforge/jest/parser.py b/src/teaforge/jest/parser.py index db644d8..aeb8c1d 100644 --- a/src/teaforge/jest/parser.py +++ b/src/teaforge/jest/parser.py @@ -8,16 +8,36 @@ from functools import lru_cache from pathlib import Path +from teaforge.discovery import discover_files +from teaforge.javascript.syntax import ( + extract_imports as extract_javascript_imports, +) +from teaforge.javascript.syntax import ( + extract_test_cases as extract_javascript_test_cases, +) +from teaforge.javascript.syntax import ( + parse_calls as parse_javascript_calls, +) +from teaforge.javascript.syntax import ( + source_defines_symbol, +) +from teaforge.pcl.assembly import build_input_rows, build_output_rows, merge_documents_by_subject from teaforge.pcl.classification import classify_type from teaforge.pcl.models import PCLDocument, PCLTestCase from teaforge.pcl.parser import ( - _build_input_rows, - _build_output_rows, _display_path, - _merge_documents_by_subject, ) -TEST_FILE_SUFFIXES = (".test.ts", ".spec.ts", ".test.js", ".spec.js") +TEST_FILE_SUFFIXES = ( + ".test.ts", + ".spec.ts", + ".test.tsx", + ".spec.tsx", + ".test.js", + ".spec.js", + ".test.jsx", + ".spec.jsx", +) DECLARATION_PREFIXES = ("const", "let", "var") @@ -40,6 +60,8 @@ class JestTestBlock: body: str identifier: str context: dict[str, object] + suffix: str = ".ts" + full_name: str = "" @dataclass(slots=True, frozen=True) @@ -59,13 +81,20 @@ def parse_jest_documents(test_path: Path) -> list[PCLDocument]: for file_path in files: content = file_path.read_text(encoding="utf-8") imports = _extract_imports(file_path, content) - for index, block in enumerate(_extract_test_blocks(content), start=1): + for index, block in enumerate( + _extract_test_blocks( + content, + suffix=file_path.suffix, + source_name=str(file_path), + ), + start=1, + ): raw_documents.append(_build_document_for_block(file_path, block, imports, index)) if not raw_documents: raise ValueError(f"No supported Jest tests found under: {test_path}") - return _merge_documents_by_subject(raw_documents) + return merge_documents_by_subject(raw_documents) def _build_document_for_block( @@ -85,7 +114,13 @@ def _build_document_for_block( test_source_path=_display_path(file_path), ) - inputs = _extract_case_inputs(block.body, imports, subject.method, block.context) + inputs = _extract_case_inputs( + block.body, + imports, + subject.method, + block.context, + suffix=block.suffix, + ) output_checks = _extract_output_expectations(block.body, block.context) output = "; ".join(output_checks) if output_checks else "No explicit expect() assertion found" document.testcases.append( @@ -97,22 +132,18 @@ def _build_document_for_block( output=output, type=classify_type(block.title, block.identifier, output), output_checks=output_checks, + test_full_name=block.full_name or block.title, + test_source_path=_display_path(file_path), ) ) - document.input_rows = _build_input_rows(document.testcases) - document.output_rows = _build_output_rows(document.testcases) + document.input_rows = build_input_rows(document.testcases) + document.output_rows = build_output_rows(document.testcases) return document def _collect_test_files(path: Path) -> list[Path]: """Expand a file or directory input into supported Jest-style test files.""" - if path.is_file(): - return [path] if _is_test_file(path) else [] - patterns = ("*.test.ts", "*.spec.ts", "*.test.js", "*.spec.js") - files: list[Path] = [] - for pattern in patterns: - files.extend(path.rglob(pattern)) - return sorted(set(files)) + return discover_files(path, is_candidate=_is_test_file) def _is_test_file(path: Path) -> bool: @@ -121,41 +152,18 @@ def _is_test_file(path: Path) -> bool: def _extract_imports(file_path: Path, content: str) -> dict[str, JSImport]: - """Map imported bindings in a Jest file to local source files when resolvable.""" - imports: dict[str, JSImport] = {} - - named_pattern = re.compile(r"import\s*\{(?P.*?)\}\s*from\s*[\"'](?P[^\"']+)[\"']", re.DOTALL) - namespace_pattern = re.compile(r"import\s*\*\s*as\s*(?P[A-Za-z_$][\w$]*)\s*from\s*[\"'](?P[^\"']+)[\"']") - default_pattern = re.compile(r"import\s+(?P[A-Za-z_$][\w$]*)\s+from\s*[\"'](?P[^\"']+)[\"']") - - for match in named_pattern.finditer(content): - module_path = _resolve_module_path(file_path, match.group("module")) - for raw_name in match.group("names").split(","): - name = raw_name.strip() - if not name: - continue - original_name, bind_name = _split_import_alias(name) - imports[bind_name] = JSImport(file_path=module_path, original_name=original_name) - - for match in namespace_pattern.finditer(content): - alias = match.group("alias") - module_path = _resolve_module_path(file_path, match.group("module")) - imports[alias] = JSImport(file_path=module_path, original_name="*") - - for match in default_pattern.finditer(content): - alias = match.group("alias") - module_path = _resolve_module_path(file_path, match.group("module")) - imports.setdefault(alias, JSImport(file_path=module_path, original_name="default")) - - return imports - - -def _split_import_alias(token: str) -> tuple[str, str]: - """Split a named import token into original and locally bound names.""" - parts = [part.strip() for part in token.split(" as ", 1)] - if len(parts) == 2: - return parts[0], parts[1] - return token.strip(), token.strip() + """Map structural ESM/CommonJS bindings to resolvable local source files.""" + return { + binding.local_name: JSImport( + file_path=_resolve_module_path(file_path, binding.module), + original_name=binding.imported_name, + ) + for binding in extract_javascript_imports( + content, + suffix=file_path.suffix, + source_name=str(file_path), + ) + } @lru_cache(maxsize=None) @@ -183,198 +191,31 @@ def _resolve_module_path(test_file: Path, module_name: str) -> Path | None: return None -def _extract_test_blocks(content: str) -> list[JestTestBlock]: - """Extract supported `test()` and `it()` blocks from one Jest file.""" - blocks: list[JestTestBlock] = [] - pattern = re.compile(r"\b(?:it|test)(?P(?:\.(?:each|only|skip))*)\s*\(") - cursor = 0 - counter = 1 - while True: - match = pattern.search(content, cursor) - if match is None: - break - open_paren = content.find("(", match.start()) - close_paren = _find_matching(content, open_paren, "(", ")") - if close_paren == -1: - break - modifiers = match.group("modifiers") or "" - if ".each" in modifiers: - invocation_open = content.find("(", close_paren + 1) - if invocation_open == -1: - break - invocation_close = _find_matching(content, invocation_open, "(", ")") - if invocation_close == -1: - break - each_blocks = _parse_each_invocation( - rows_expression=content[open_paren + 1 : close_paren], - invocation=content[invocation_open + 1 : invocation_close], - start_index=counter, - ) - blocks.extend(each_blocks) - counter += len(each_blocks) - cursor = invocation_close + 1 - continue - - invocation = content[open_paren + 1 : close_paren] - parsed = _parse_test_invocation(invocation) - if parsed is not None: - title, body = parsed - identifier = _normalize_test_identifier(title, counter) - blocks.append( - JestTestBlock( - title=title, - body=body, - identifier=identifier, - context={}, - ) - ) - counter += 1 - cursor = close_paren + 1 - return blocks - - -def _parse_each_invocation( - rows_expression: str, - invocation: str, - start_index: int, +def _extract_test_blocks( + content: str, + *, + suffix: str = ".ts", + source_name: str = "", ) -> list[JestTestBlock]: - """Expand one `test.each(...)` invocation into one block per row.""" - title, cursor = _parse_title_and_cursor(invocation) - if title is None: - return [] - - callback = _parse_callback_signature(invocation, cursor) - if callback is None: - return [] - - parameter_names, body = callback - rows = _parse_each_rows(rows_expression) - blocks: list[JestTestBlock] = [] - for offset, row in enumerate(rows, start=0): - context = _build_each_context(parameter_names, row) - rendered_title = _render_each_title(title, parameter_names, row, context, start_index + offset) - blocks.append( - JestTestBlock( - title=rendered_title, - body=body, - identifier=_normalize_test_identifier(rendered_title, start_index + offset), - context=context, - ) + """Extract Jest tests from structural syntax evidence.""" + return [ + JestTestBlock( + title=case.title, + body=case.body, + identifier=_normalize_test_identifier(case.title, index), + context=case.context, + suffix=suffix, + full_name=case.full_name, ) - return blocks - - -def _parse_title_and_cursor(invocation: str) -> tuple[str | None, int]: - """Parse the first title string from a Jest invocation payload.""" - start = _skip_whitespace(invocation, 0) - string_result = _parse_string_literal(invocation, start) - if string_result is None: - return None, start - title, cursor = string_result - return title.strip(), cursor - - -def _parse_callback_signature(invocation: str, cursor: int) -> tuple[list[str], str] | None: - """Parse callback parameters and body from an arrow-function invocation.""" - cursor = _skip_spaces(invocation, cursor) - if cursor >= len(invocation) or invocation[cursor] != ",": - return None - cursor = _skip_spaces(invocation, cursor + 1) - - if cursor < len(invocation) and invocation[cursor] == "(": - params_end = _find_matching(invocation, cursor, "(", ")") - if params_end == -1: - return None - parameter_names = [ - name.strip() for name in _split_top_level(invocation[cursor + 1 : params_end], ",") if name.strip() - ] - body = _extract_callback_body(invocation, params_end + 1) - if body is None: - return None - return parameter_names, body.strip() - - arrow_index = invocation.find("=>", cursor) - if arrow_index == -1: - return None - parameter_name = invocation[cursor:arrow_index].strip() - body = _extract_callback_body(invocation, arrow_index) - if body is None: - return None - return ([parameter_name] if parameter_name else []), body.strip() - - -def _parse_each_rows(rows_expression: str) -> list[object]: - """Parse a supported `test.each` rows expression into row payloads.""" - parsed = _parse_literal_expression(rows_expression) - if isinstance(parsed, list): - return parsed - return [] - - -def _build_each_context(parameter_names: list[str], row: object) -> dict[str, object]: - """Map one parsed `test.each` row to callback parameter names.""" - if isinstance(row, dict): - return dict(row) - if isinstance(row, list): - return { - name: row[index] for index, name in enumerate(parameter_names) if index < len(row) - } - if len(parameter_names) == 1: - return {parameter_names[0]: row} - return {} - - -def _render_each_title( - title: str, - parameter_names: list[str], - row: object, - context: dict[str, object], - index: int, -) -> str: - """Render `%` and `$name` placeholders for one `test.each` row.""" - rendered = title - if isinstance(row, list): - row_values = [_stringify_value(value) for value in row] - else: - row_values = [_stringify_value(context[name]) for name in parameter_names if name in context] - - for token in ("%s", "%d", "%i", "%f", "%p", "%j", "%o"): - while token in rendered and row_values: - rendered = rendered.replace(token, row_values.pop(0), 1) - - for name, value in context.items(): - rendered = rendered.replace(f"${name}", _stringify_value(value)) - - if rendered == title: - rendered = f"{title} case {index:02d}" - return rendered - - -def _parse_test_invocation(invocation: str) -> tuple[str, str] | None: - """Parse the title and callback body from one Jest invocation payload.""" - start = _skip_whitespace(invocation, 0) - string_result = _parse_string_literal(invocation, start) - if string_result is None: - return None - title, cursor = string_result - callback_body = _extract_callback_body(invocation, cursor) - if callback_body is None: - return None - return title.strip(), callback_body.strip() - - -def _skip_whitespace(text: str, cursor: int) -> int: - """Advance past whitespace and commas.""" - while cursor < len(text) and text[cursor] in " \t\r\n,": - cursor += 1 - return cursor - - -def _skip_spaces(text: str, cursor: int) -> int: - """Advance past whitespace without consuming punctuation.""" - while cursor < len(text) and text[cursor] in " \t\r\n": - cursor += 1 - return cursor + for index, case in enumerate( + extract_javascript_test_cases( + content, + suffix=suffix, + source_name=source_name, + ), + start=1, + ) + ] def _parse_string_literal(text: str, cursor: int) -> tuple[str, int] | None: @@ -397,22 +238,6 @@ def _parse_string_literal(text: str, cursor: int) -> tuple[str, int] | None: return None -def _extract_callback_body(invocation: str, cursor: int) -> str | None: - """Extract the callback block body from an arrow/function callback.""" - arrow_index = invocation.find("=>", cursor) - function_index = invocation.find("function", cursor) - if arrow_index == -1 and function_index == -1: - return None - search_from = arrow_index + 2 if arrow_index != -1 else function_index + len("function") - open_brace = invocation.find("{", search_from) - if open_brace == -1: - return None - close_brace = _find_matching(invocation, open_brace, "{", "}") - if close_brace == -1: - return None - return invocation[open_brace + 1 : close_brace] - - def _find_matching(text: str, open_index: int, open_char: str, close_char: str) -> int: """Find the matching closing delimiter while skipping strings and comments.""" depth = 0 @@ -460,12 +285,12 @@ def _infer_subject_location( imports: dict[str, JSImport], ) -> SubjectLocation: """Infer the tested production file and function from imported function calls.""" - for call in _iter_calls_in_order(block.body): + for call in _iter_calls_in_order(block.body, suffix=block.suffix): subject = _subject_from_call(call, imports) if subject is not None: return subject - for nested_call in _iter_calls_in_order(call.arg_text): + for nested_call in _iter_calls_in_order(call.arg_text, suffix=block.suffix): subject = _subject_from_call(nested_call, imports) if subject is not None: return subject @@ -489,17 +314,27 @@ def _subject_from_call(call: CallMatch, imports: dict[str, JSImport]) -> Subject imported = imports.get(call.callee) if imported is None or imported.file_path is None: return None - symbol_name = imported.original_name if imported.original_name != "default" else call.callee - return _resolve_symbol_subject(imported.file_path, symbol_name) + if imported.original_name == "default": + return _resolve_symbol_subject( + imported.file_path, + "default", + method_name=call.callee, + ) + return _resolve_symbol_subject(imported.file_path, imported.original_name) -def _resolve_symbol_subject(module_path: Path, symbol_name: str) -> SubjectLocation | None: +def _resolve_symbol_subject( + module_path: Path, + symbol_name: str, + *, + method_name: str | None = None, +) -> SubjectLocation | None: """Build a subject descriptor when the imported module really defines the symbol.""" if not _module_defines_symbol(module_path, symbol_name): return None return SubjectLocation( file=module_path.name, - method=symbol_name, + method=method_name or symbol_name, source_path=_display_path(module_path), ) @@ -510,34 +345,28 @@ def _module_defines_symbol(module_path: Path, symbol_name: str) -> bool: if not module_path.exists() or module_path.suffix not in {".ts", ".tsx", ".js", ".jsx"}: return False source = module_path.read_text(encoding="utf-8") - patterns = ( - rf"\bexport\s+(?:async\s+)?function\s+{re.escape(symbol_name)}\b", - rf"\b(?:export\s+)?(?:const|let|var)\s+{re.escape(symbol_name)}\s*=", - rf"\bexport\s+class\s+{re.escape(symbol_name)}\b", - rf"\bclass\s+{re.escape(symbol_name)}\b", + return source_defines_symbol( + source, + symbol_name, + suffix=module_path.suffix, + source_name=str(module_path), ) - return any(re.search(pattern, source) for pattern in patterns) -def _iter_calls_in_order(body: str) -> list[CallMatch]: +def _iter_calls_in_order(body: str, *, suffix: str = ".ts") -> list[CallMatch]: """Return function calls in source order for subject inference and input extraction.""" - calls: list[CallMatch] = [] - pattern = re.compile(r"\b(?:(?P[A-Za-z_$][\w$]*)\.)?(?P[A-Za-z_$][\w$]*)\s*\(") - cursor = 0 - while True: - match = pattern.search(body, cursor) - if match is None: - break - open_paren = body.find("(", match.start()) - close_paren = _find_matching(body, open_paren, "(", ")") - if close_paren == -1: - break - namespace = match.group("namespace") - name = match.group("name") - callee = f"{namespace}.{name}" if namespace else name - calls.append(CallMatch(callee=callee, arg_text=body[open_paren + 1 : close_paren], start=match.start())) - cursor = close_paren + 1 - return calls + return [ + CallMatch( + callee=call.callee, + arg_text=call.argument_text, + start=call.start_byte, + ) + for call in parse_javascript_calls( + body, + suffix=suffix, + source_name="Jest callback", + ) + ] def _extract_case_inputs( @@ -545,9 +374,11 @@ def _extract_case_inputs( imports: dict[str, JSImport], subject_method: str, context: dict[str, object], + *, + suffix: str = ".ts", ) -> dict[str, str]: """Extract one testcase input dictionary from the first matching subject call.""" - for call in _iter_calls_in_order(body): + for call in _iter_calls_in_order(body, suffix=suffix): if call.callee == subject_method or call.callee.endswith(f".{subject_method}"): return _render_inputs_from_call(body, call.arg_text, context) if call.callee in imports: @@ -555,7 +386,7 @@ def _extract_case_inputs( if imported.original_name == subject_method: return _render_inputs_from_call(body, call.arg_text, context) - for nested_call in _iter_calls_in_order(call.arg_text): + for nested_call in _iter_calls_in_order(call.arg_text, suffix=suffix): if nested_call.callee == subject_method or nested_call.callee.endswith( f".{subject_method}" ): @@ -764,4 +595,4 @@ def _format_expectation( return [f"{actual} == {rendered_expected}"] if matcher == "toThrow": return [f"raises {rendered_expected or 'Error'}"] - return [] \ No newline at end of file + return [] diff --git a/src/teaforge/jest/project.py b/src/teaforge/jest/project.py new file mode 100644 index 0000000..c88dc1c --- /dev/null +++ b/src/teaforge/jest/project.py @@ -0,0 +1,136 @@ +"""Discover and execute the target project's own Jest installation safely.""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping, Sequence + +from teaforge.discovery import discover_files +from teaforge.jest.parser import TEST_FILE_SUFFIXES +from teaforge.process import ( + ProcessResult, + bounded_process_detail, + run_process, +) + + +@dataclass(slots=True, frozen=True) +class JestProject: + """Resolved Jest execution context for one test path.""" + + root: Path + executable: Path + test_files: tuple[Path, ...] + + @classmethod + def discover(cls, test_path: Path) -> "JestProject": + """Resolve test files, config root, and the nearest installed Jest binary.""" + test_files = tuple(path.resolve() for path in collect_jest_test_files(test_path)) + if not test_files: + raise RuntimeError(f"No executable Jest test files found under: {test_path}") + root = discover_jest_project_root(test_path) + return cls( + root=root, + executable=find_local_jest(root), + test_files=test_files, + ) + + def run( + self, + arguments: Sequence[str], + *, + timeout_seconds: float, + operation: str, + environment: Mapping[str, str] | None = None, + ) -> ProcessResult: + """Run Jest with stable cwd, timeout, and actionable process errors.""" + command = [str(self.executable), *arguments] + try: + return run_process( + command, + cwd=self.root, + environment=( + dict(environment) if environment is not None else os.environ.copy() + ), + timeout_seconds=timeout_seconds, + operation=operation, + ) + except FileNotFoundError as exc: + raise RuntimeError( + f"The project-local Jest executable disappeared: {self.executable}. " + "Reinstall the target project's dependencies." + ) from exc + + def setup_files_after_env(self, *, timeout_seconds: float) -> list[str]: + """Read resolved setup files so listener injection preserves project behavior.""" + result = self.run( + ["--showConfig", "--json"], + timeout_seconds=timeout_seconds, + operation="Reading the Jest configuration", + ) + if result.returncode != 0: + detail = bounded_process_detail(result.stderr, result.stdout) + raise RuntimeError( + "TeaForge could not read the Jest configuration before injecting its listener. " + f"Exit code: {result.returncode}. {detail}" + ) + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError( + "Jest --showConfig did not return valid JSON; TeaForge refused to replace " + "the project's setupFilesAfterEnv configuration." + ) from exc + + setup_files: list[str] = [] + for config in payload.get("configs", []): + for setup_file in config.get("setupFilesAfterEnv", []): + normalized = str(setup_file) + if normalized and normalized not in setup_files: + setup_files.append(normalized) + return setup_files + + +def discover_jest_project_root(test_path: Path) -> Path: + """Find the nearest directory that owns Jest/package configuration.""" + start = test_path.resolve() if test_path.is_dir() else test_path.resolve().parent + for candidate in (start, *start.parents): + if any( + (candidate / marker).exists() + for marker in ( + "package.json", + "jest.config.js", + "jest.config.cjs", + "jest.config.mjs", + "jest.config.ts", + ) + ): + return candidate + raise RuntimeError( + f"Could not find a Jest project root for: {test_path}. " + "TeaForge looks for package.json or jest.config.* in parent directories." + ) + + +def find_local_jest(project_root: Path) -> Path: + """Find the nearest ancestor-installed Jest without invoking npx downloads.""" + executable_name = "jest.cmd" if os.name == "nt" else "jest" + for candidate in (project_root, *project_root.parents): + executable = candidate / "node_modules" / ".bin" / executable_name + if executable.exists() and executable.is_file(): + return executable + raise RuntimeError( + f"No project-local Jest executable found from: {project_root}. " + "Install the target project's dependencies before running TeaForge." + ) + + +def collect_jest_test_files(test_path: Path) -> list[Path]: + """Expand a file or directory into exact Jest test paths.""" + return discover_files( + test_path, + is_candidate=lambda path: path.name.endswith(TEST_FILE_SUFFIXES), + ) diff --git a/src/teaforge/jest/runtime.py b/src/teaforge/jest/runtime.py new file mode 100644 index 0000000..cb32128 --- /dev/null +++ b/src/teaforge/jest/runtime.py @@ -0,0 +1,267 @@ +"""Capture observed Jest assertion evidence without modifying the target project.""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from importlib import resources +from pathlib import Path + +from teaforge.jest.project import JestProject +from teaforge.pcl.assembly import build_output_rows +from teaforge.pcl.models import PCLAssertionEvidence, PCLDocument, PCLTestCase +from teaforge.process import WorkflowDeadline, bounded_process_detail + +EVIDENCE_MODES = ("static", "runtime", "auto") +EVIDENCE_SCHEMA_VERSION = 1 +MAX_EVIDENCE_FILE_BYTES = 6 * 1024 * 1024 +MAX_EVIDENCE_RECORDS = 6000 + + +@dataclass(slots=True, frozen=True) +class JestAssertionEvidence: + test_name: str + test_path: str + matcher: str + expected: list[str] + actual: str + passed: bool + negated: bool = False + promise_mode: str = "" + error: str = "" + + +@dataclass(slots=True, frozen=True) +class JestEvidenceRun: + evidence: list[JestAssertionEvidence] + exit_code: int + stdout: str = "" + stderr: str = "" + warnings: tuple[str, ...] = () + + @property + def tests_passed(self) -> bool: + return self.exit_code == 0 + + +class JestRuntimeEvidenceUnavailableError(RuntimeError): + """Runtime evidence cannot start or the target uses unsupported assertion hooks.""" + + code = "jest-runtime-evidence-unavailable" + + +def normalize_evidence_mode(mode: str) -> str: + normalized = mode.strip().lower() + if normalized not in EVIDENCE_MODES: + supported = ", ".join(EVIDENCE_MODES) + raise ValueError(f"Unsupported evidence mode: {mode}. Supported values: {supported}") + return normalized + + +def capture_jest_assertions( + test_path: Path, + *, + timeout_seconds: int = 120, +) -> list[JestAssertionEvidence]: + """Compatibility wrapper returning only assertions from one Jest evidence run.""" + return capture_jest_run( + test_path, + timeout_seconds=timeout_seconds, + ).evidence + + +def capture_jest_run( + test_path: Path, + *, + timeout_seconds: int = 120, +) -> JestEvidenceRun: + """Execute project-local Jest and preserve both evidence and process status.""" + try: + project = JestProject.discover(test_path) + except RuntimeError as exc: + raise JestRuntimeEvidenceUnavailableError(str(exc)) from exc + deadline = WorkflowDeadline.start( + "Jest runtime evidence workflow", + timeout_seconds, + ) + setup_files = project.setup_files_after_env( + timeout_seconds=deadline.remaining_seconds() + ) + + import tempfile + + with tempfile.TemporaryDirectory(prefix="teaforge-jest-evidence-") as temp_dir: + evidence_path = Path(temp_dir) / "assertions.jsonl" + listener_resource = resources.files("teaforge.jest.assets").joinpath( + "runtime-listener.cjs" + ) + with resources.as_file(listener_resource) as listener_path: + command = [ + "--runInBand", + "--runTestsByPath", + *[str(path) for path in project.test_files], + ] + for setup_file in [*setup_files, str(listener_path)]: + command.extend(["--setupFilesAfterEnv", setup_file]) + environment = os.environ.copy() + environment["TEAFORGE_JEST_EVIDENCE_PATH"] = str(evidence_path) + result = project.run( + command, + timeout_seconds=deadline.remaining_seconds(), + operation="Jest runtime evidence", + environment=environment, + ) + + evidence, warnings = load_jest_evidence(evidence_path) + if evidence: + return JestEvidenceRun( + evidence=evidence, + exit_code=result.returncode, + stdout=result.stdout, + stderr=result.stderr, + warnings=tuple(warnings), + ) + + detail = bounded_process_detail(result.stderr, result.stdout) + if result.returncode != 0: + raise RuntimeError( + "Jest failed before TeaForge captured assertion evidence. " + f"Exit code: {result.returncode}. {detail}" + ) + raise JestRuntimeEvidenceUnavailableError( + "Jest completed but no supported expect() assertions were observed. " + "Use --evidence-mode static for tests that import a separate expect implementation." + ) + + +def enrich_documents_with_runtime_evidence( + documents: list[PCLDocument], + evidence: list[JestAssertionEvidence], +) -> int: + """Attach observed assertions to statically parsed testcases without replacing design intent.""" + matched_count = 0 + for document in documents: + document_changed = False + for case in document.testcases: + matches = [ + item + for item in evidence + if _evidence_matches_case(item, case, document.test_file) + ] + if not matches: + continue + case.assertion_evidence = [ + PCLAssertionEvidence( + matcher=item.matcher, + expected=item.expected, + actual=item.actual, + passed=item.passed, + negated=item.negated, + promise_mode=item.promise_mode, + error=item.error, + ) + for item in matches + ] + case.execution_status = ( + "passed" if all(item.passed for item in matches) else "failed" + ) + case.runtime_test_name = matches[0].test_name + case.runtime_test_path = matches[0].test_path + document_changed = True + matched_count += 1 + if document_changed: + document.output_rows = build_output_rows(document.testcases) + return matched_count + + +def load_jest_assertions(path: Path) -> list[JestAssertionEvidence]: + """Load assertion records while preserving the historical list-only API.""" + evidence, _warnings = load_jest_evidence(path) + return evidence + + +def load_jest_evidence( + path: Path, +) -> tuple[list[JestAssertionEvidence], list[str]]: + """Load bounded, versioned assertion evidence and producer warnings.""" + if not path.exists(): + return [], [] + file_size = path.stat().st_size + if file_size > MAX_EVIDENCE_FILE_BYTES: + raise RuntimeError( + "Jest evidence file is too large to load safely: " + f"{file_size} bytes (limit: {MAX_EVIDENCE_FILE_BYTES})." + ) + evidence: list[JestAssertionEvidence] = [] + warnings: list[str] = [] + with path.open(encoding="utf-8") as evidence_file: + for line_number, raw_line in enumerate(evidence_file, start=1): + if line_number > MAX_EVIDENCE_RECORDS: + raise RuntimeError( + "Jest evidence contains too many records to load safely " + f"(limit: {MAX_EVIDENCE_RECORDS})." + ) + if not raw_line.strip(): + continue + try: + payload = json.loads(raw_line) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"Invalid Jest evidence JSON on line {line_number}: {exc.msg}" + ) from exc + if payload.get("schema_version") != EVIDENCE_SCHEMA_VERSION: + raise RuntimeError( + "Unsupported Jest evidence schema on line " + f"{line_number}: {payload.get('schema_version')!r}." + ) + kind = payload.get("kind") + if kind == "warning": + message = str(payload.get("message", "Jest evidence was truncated.")) + warnings.append(message) + continue + if kind != "assertion": + raise RuntimeError( + f"Unsupported Jest evidence record kind on line {line_number}: {kind!r}." + ) + expected = payload.get("expected", []) + if not isinstance(expected, list): + raise RuntimeError( + f"Invalid Jest evidence expected values on line {line_number}: list required." + ) + evidence.append( + JestAssertionEvidence( + test_name=str(payload.get("test_name", "")), + test_path=str(payload.get("test_path", "")), + matcher=str(payload.get("matcher", "")), + expected=[str(value) for value in expected], + actual=str(payload.get("actual", "")), + passed=bool(payload.get("passed", False)), + negated=bool(payload.get("negated", False)), + promise_mode=str(payload.get("promise_mode", "")), + error=str(payload.get("error", "")), + ) + ) + return evidence, warnings + + +def _evidence_matches_case( + evidence: JestAssertionEvidence, + testcase: PCLTestCase, + document_test_file: str, +) -> bool: + if testcase.test_full_name: + name_matches = evidence.test_name == testcase.test_full_name + else: + name_matches = evidence.test_name.endswith(testcase.testcase) + if not name_matches: + return False + if not evidence.test_path: + return True + if testcase.test_source_path: + return Path(evidence.test_path).resolve() == Path( + testcase.test_source_path + ).resolve() + if document_test_file.startswith("複数"): + return True + return Path(evidence.test_path).name == document_test_file diff --git a/src/teaforge/naming.py b/src/teaforge/naming.py index 2b0326d..5581711 100644 --- a/src/teaforge/naming.py +++ b/src/teaforge/naming.py @@ -2,7 +2,12 @@ from __future__ import annotations +import hashlib +import os import re +from collections import defaultdict +from pathlib import Path +from typing import Iterable def slugify(value: str) -> str: @@ -15,3 +20,37 @@ def folder_name(value: str) -> str: """Create snake_case names for grouping output directories.""" name = re.sub(r"[^a-zA-Z0-9_]+", "_", value).strip("_").lower() return name or "item" + + +def source_folder_names(source_paths: Iterable[Path]) -> dict[Path, str]: + """Build collision-free, mostly human-readable folders for source artifacts.""" + paths = list(dict.fromkeys(path.resolve() for path in source_paths)) + grouped: defaultdict[str, list[Path]] = defaultdict(list) + for path in paths: + grouped[folder_name(path.stem)].append(path) + + result: dict[Path, str] = {} + for base_name, related in grouped.items(): + if len(related) == 1: + result[related[0]] = base_name + continue + try: + common_root = Path(os.path.commonpath([str(path) for path in related])) + if common_root.suffix: + common_root = common_root.parent + except ValueError: + common_root = None + + used: set[str] = set() + for path in related: + if common_root is not None: + relative = path.with_suffix("").relative_to(common_root) + candidate = folder_name("_".join(relative.parts)) + else: + candidate = folder_name(f"{path.parent.name}_{path.stem}") + if candidate in used: + digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:8] + candidate = f"{candidate}_{digest}" + used.add(candidate) + result[path] = candidate + return result diff --git a/src/teaforge/pcl/__init__.py b/src/teaforge/pcl/__init__.py index a3c119c..a519861 100644 --- a/src/teaforge/pcl/__init__.py +++ b/src/teaforge/pcl/__init__.py @@ -1,7 +1,8 @@ -from .models import PCLDocument, PCLMatrixRow, PCLTestCase +from .models import PCLAssertionEvidence, PCLDocument, PCLMatrixRow, PCLTestCase from .service import generate_pcl, get_testcase_description, load_pcl_document __all__ = [ + "PCLAssertionEvidence", "PCLDocument", "PCLMatrixRow", "PCLTestCase", @@ -9,4 +10,3 @@ "load_pcl_document", "get_testcase_description", ] - diff --git a/src/teaforge/pcl/assembly.py b/src/teaforge/pcl/assembly.py new file mode 100644 index 0000000..e359ffd --- /dev/null +++ b/src/teaforge/pcl/assembly.py @@ -0,0 +1,125 @@ +"""Assemble framework-neutral test evidence into PCL documents and matrices.""" + +from __future__ import annotations + +from collections import OrderedDict +from dataclasses import replace + +from .models import PCLDocument, PCLMatrixRow, PCLTestCase + + +def merge_documents_by_subject(documents: list[PCLDocument]) -> list[PCLDocument]: + """Group intermediate documents by their inferred production subject.""" + grouped: OrderedDict[tuple[str, str, str], list[PCLDocument]] = OrderedDict() + for document in documents: + key = (document.file, document.method, document.source_path) + grouped.setdefault(key, []).append(document) + + return [ + related[0] if len(related) == 1 else _merge_related_documents(related) + for related in grouped.values() + ] + + +def build_input_rows(cases: list[PCLTestCase]) -> list[PCLMatrixRow]: + """Build the PCL input matrix rows from testcase input values.""" + row_hits: OrderedDict[tuple[str, str], dict[str, str]] = OrderedDict() + for case in cases: + for item, value in case.inputs.items(): + key = (item, value) + row_hits.setdefault(key, {}) + row_hits[key][case.testcasecode] = "○" + + return _build_rows("input", cases, row_hits) + + +def build_output_rows(cases: list[PCLTestCase]) -> list[PCLMatrixRow]: + """Build the PCL output matrix rows from testcase assertions.""" + row_hits: OrderedDict[tuple[str, str], dict[str, str]] = OrderedDict() + for case in cases: + checks = case.output_checks or ([case.output] if case.output else []) + for check in checks: + item, value = _split_output_check(check) + key = (item, value) + row_hits.setdefault(key, {}) + row_hits[key][case.testcasecode] = "○" + + return _build_rows("output", cases, row_hits) + + +def _merge_related_documents(documents: list[PCLDocument]) -> PCLDocument: + """Merge test documents that target the same production subject.""" + first = documents[0] + merged = PCLDocument.create( + file=first.file, + method=first.method, + source_path=first.source_path, + test_file=_merge_source_labels([document.test_file for document in documents]), + test_method=_merge_source_labels([document.test_method for document in documents]), + test_source_path=_merge_source_labels( + [document.test_source_path for document in documents] + ), + ) + merged.generated_at = first.generated_at + + case_idx = 1 + for document in documents: + for case in document.testcases: + merged.testcases.append(replace(case, testcasecode=f"TC-{case_idx:03d}")) + case_idx += 1 + + merged.input_rows = build_input_rows(merged.testcases) + merged.output_rows = build_output_rows(merged.testcases) + return merged + + +def _merge_source_labels(values: list[str]) -> str: + unique_values = list(dict.fromkeys(value for value in values if value)) + if not unique_values: + return "" + if len(unique_values) == 1: + return unique_values[0] + return f"複数 ({len(unique_values)} 件)" + + +def _build_rows( + category: str, + cases: list[PCLTestCase], + row_hits: OrderedDict[tuple[str, str], dict[str, str]], +) -> list[PCLMatrixRow]: + rows: list[PCLMatrixRow] = [] + for (item, value), hits in _group_keys_by_item(row_hits).items(): + rows.append( + PCLMatrixRow( + category=category, + item=item, + value=value, + values={case.testcasecode: hits.get(case.testcasecode, "") for case in cases}, + ) + ) + return rows + + +def _split_output_check(check: str) -> tuple[str, str]: + if check.startswith("raises "): + return ("raises", check.removeprefix("raises ").strip()) + if " == " in check: + left, right = check.split(" == ", 1) + return (left.strip(), right.strip()) + return ("assertion", check) + + +def _group_keys_by_item( + row_hits: OrderedDict[tuple[str, str], dict[str, str]], +) -> OrderedDict[tuple[str, str], dict[str, str]]: + item_groups: OrderedDict[ + str, list[tuple[tuple[str, str], dict[str, str]]] + ] = OrderedDict() + for key, hits in row_hits.items(): + item_groups.setdefault(key[0], []).append((key, hits)) + + grouped: OrderedDict[tuple[str, str], dict[str, str]] = OrderedDict() + for entries in item_groups.values(): + for key, hits in entries: + grouped[key] = hits + return grouped diff --git a/src/teaforge/pcl/backends.py b/src/teaforge/pcl/backends.py index 4c56bd1..3a22902 100644 --- a/src/teaforge/pcl/backends.py +++ b/src/teaforge/pcl/backends.py @@ -19,9 +19,17 @@ def normalize_pcl_framework(framework: str) -> str: return normalized -def parse_documents_for_framework(test_path: Path, framework: str) -> list[PCLDocument]: +def parse_documents_for_framework( + test_path: Path, + framework: str, + *, + evidence_mode: str = "static", + runtime_timeout: int = 120, +) -> list[PCLDocument]: """Route one test path through the selected framework parser.""" normalized = normalize_pcl_framework(framework) + if evidence_mode != "static" and normalized != "jest": + raise ValueError("Runtime assertion evidence is currently supported only for Jest.") if normalized == "pytest": return parse_pytest_documents(test_path) @@ -36,5 +44,43 @@ def parse_documents_for_framework(test_path: Path, framework: str) -> list[PCLDo return parse_playwright_documents(test_path) from teaforge.jest.parser import parse_jest_documents + from teaforge.jest.runtime import ( + JestRuntimeEvidenceUnavailableError, + capture_jest_run, + enrich_documents_with_runtime_evidence, + normalize_evidence_mode, + ) - return parse_jest_documents(test_path) \ No newline at end of file + mode = normalize_evidence_mode(evidence_mode) + documents = parse_jest_documents(test_path) + if mode == "static": + return documents + + try: + run = capture_jest_run(test_path, timeout_seconds=runtime_timeout) + matched_count = enrich_documents_with_runtime_evidence( + documents, run.evidence + ) + if matched_count == 0: + raise JestRuntimeEvidenceUnavailableError( + "Jest produced assertion evidence, but it could not be matched to parsed testcases." + ) + except JestRuntimeEvidenceUnavailableError as exc: + if mode == "runtime": + raise + for document in documents: + document.evidence_mode = "static-fallback" + document.evidence_warnings.append(str(exc)) + return documents + + for document in documents: + document.evidence_mode = "runtime" + document.runtime_exit_code = run.exit_code + document.runtime_tests_passed = run.tests_passed + document.evidence_warnings.extend(run.warnings) + if not run.tests_passed: + document.evidence_warnings.append( + f"Jest completed with failing tests (exit code {run.exit_code}); " + "the report was generated from the captured failure evidence." + ) + return documents diff --git a/src/teaforge/pcl/classification.py b/src/teaforge/pcl/classification.py index 07885f1..25c1278 100644 --- a/src/teaforge/pcl/classification.py +++ b/src/teaforge/pcl/classification.py @@ -2,7 +2,6 @@ from __future__ import annotations - NORMAL_HINTS = ("normal", "success", "valid", "ok", "happy") BOUNDARY_HINTS = ("boundary", "edge", "limit", "min", "max", "minlength", "maxlength") EXCEPTION_HINTS = ( diff --git a/src/teaforge/pcl/models.py b/src/teaforge/pcl/models.py index ac47f3f..90d1051 100644 --- a/src/teaforge/pcl/models.py +++ b/src/teaforge/pcl/models.py @@ -6,6 +6,8 @@ from datetime import UTC, datetime from typing import Any +PCL_SCHEMA_VERSION = 1 + @dataclass(slots=True) class PCLMatrixRow: @@ -15,6 +17,18 @@ class PCLMatrixRow: values: dict[str, str] +@dataclass(slots=True) +class PCLAssertionEvidence: + matcher: str + expected: list[str] + actual: str + passed: bool + negated: bool = False + promise_mode: str = "" + source: str = "jest-runtime" + error: str = "" + + @dataclass(slots=True) class PCLTestCase: testcase: str @@ -31,6 +45,12 @@ class PCLTestCase: navigation_outputs: list[str] = field(default_factory=list) verification_mode: str = "" output_checks: list[str] = field(default_factory=list) + test_full_name: str = "" + test_source_path: str = "" + assertion_evidence: list[PCLAssertionEvidence] = field(default_factory=list) + execution_status: str = "" + runtime_test_name: str = "" + runtime_test_path: str = "" executed_date: str = "" bug_number: str = "" @@ -45,6 +65,11 @@ class PCLDocument: test_method: str test_source_path: str generated_at: str + schema_version: int = PCL_SCHEMA_VERSION + evidence_mode: str = "static" + evidence_warnings: list[str] = field(default_factory=list) + runtime_exit_code: int | None = None + runtime_tests_passed: bool | None = None sheet_number: int = 1 sheet_count: int = 1 testcases: list[PCLTestCase] = field(default_factory=list) @@ -72,6 +97,11 @@ def create( test_method=test_method or method, test_source_path=test_source_path or source_path, generated_at=datetime.now(UTC).isoformat(), + schema_version=PCL_SCHEMA_VERSION, + evidence_mode="static", + evidence_warnings=[], + runtime_exit_code=None, + runtime_tests_passed=None, sheet_number=1, sheet_count=1, ) @@ -83,6 +113,12 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, payload: dict[str, Any]) -> "PCLDocument": """Rebuild the nested dataclasses from JSON payloads embedded in reports.""" + schema_version = int(payload.get("schema_version", 0)) + if schema_version not in (0, PCL_SCHEMA_VERSION): + raise ValueError( + f"Unsupported PCL schema version: {schema_version}. " + f"This TeaForge release supports version {PCL_SCHEMA_VERSION}." + ) return cls( title=payload["title"], file=payload["file"], @@ -92,6 +128,11 @@ def from_dict(cls, payload: dict[str, Any]) -> "PCLDocument": test_method=payload.get("test_method", payload["method"]), test_source_path=payload.get("test_source_path", payload["source_path"]), generated_at=payload["generated_at"], + schema_version=PCL_SCHEMA_VERSION, + evidence_mode=payload.get("evidence_mode", "static"), + evidence_warnings=payload.get("evidence_warnings", []), + runtime_exit_code=payload.get("runtime_exit_code"), + runtime_tests_passed=payload.get("runtime_tests_passed"), sheet_number=payload.get("sheet_number", 1), sheet_count=payload.get("sheet_count", 1), testcases=[ @@ -110,6 +151,24 @@ def from_dict(cls, payload: dict[str, Any]) -> "PCLDocument": navigation_outputs=row.get("navigation_outputs", []), verification_mode=row.get("verification_mode", ""), output_checks=row.get("output_checks", []), + test_full_name=row.get("test_full_name", ""), + test_source_path=row.get("test_source_path", ""), + assertion_evidence=[ + PCLAssertionEvidence( + matcher=evidence.get("matcher", ""), + expected=evidence.get("expected", []), + actual=evidence.get("actual", ""), + passed=bool(evidence.get("passed", False)), + negated=bool(evidence.get("negated", False)), + promise_mode=evidence.get("promise_mode", ""), + source=evidence.get("source", "jest-runtime"), + error=evidence.get("error", ""), + ) + for evidence in row.get("assertion_evidence", []) + ], + execution_status=row.get("execution_status", ""), + runtime_test_name=row.get("runtime_test_name", ""), + runtime_test_path=row.get("runtime_test_path", ""), executed_date=row.get("executed_date", ""), bug_number=row.get("bug_number", ""), ) diff --git a/src/teaforge/pcl/parser.py b/src/teaforge/pcl/parser.py index f1999b7..92d1d4c 100644 --- a/src/teaforge/pcl/parser.py +++ b/src/teaforge/pcl/parser.py @@ -10,8 +10,11 @@ from pathlib import Path from typing import Any +from teaforge.discovery import discover_files + +from .assembly import build_input_rows, build_output_rows, merge_documents_by_subject from .classification import classify_type -from .models import PCLDocument, PCLMatrixRow, PCLTestCase +from .models import PCLDocument, PCLTestCase KNOWN_FIXTURE_NAMES = { "cache", @@ -58,14 +61,14 @@ def parse_pytest_documents(pytest_path: Path) -> list[PCLDocument]: raw_documents: list[PCLDocument] = [] for file_path in files: tree = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path)) - imports = _extract_imports(tree) + imports = _extract_imports(tree, file_path) for node in tree.body: if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith( "test_" ): raw_documents.append(_build_document_for_function(file_path, node, imports)) - return _merge_documents_by_subject(raw_documents) + return merge_documents_by_subject(raw_documents) def parse_pytest_path(pytest_path: Path) -> PCLDocument: @@ -85,8 +88,8 @@ def parse_pytest_path(pytest_path: Path) -> PCLDocument: merged.testcases.append(replace(case, testcasecode=f"TC-{case_idx:03d}")) case_idx += 1 - merged.input_rows = _build_input_rows(merged.testcases) - merged.output_rows = _build_output_rows(merged.testcases) + merged.input_rows = build_input_rows(merged.testcases) + merged.output_rows = build_output_rows(merged.testcases) return merged @@ -119,61 +122,11 @@ def _build_document_for_function( ) ) - document.input_rows = _build_input_rows(document.testcases) - document.output_rows = _build_output_rows(document.testcases) + document.input_rows = build_input_rows(document.testcases) + document.output_rows = build_output_rows(document.testcases) return document -def _merge_documents_by_subject(documents: list[PCLDocument]) -> list[PCLDocument]: - """Group intermediate documents by the inferred production subject.""" - grouped: OrderedDict[tuple[str, str, str], list[PCLDocument]] = OrderedDict() - for document in documents: - key = (document.file, document.method, document.source_path) - grouped.setdefault(key, []).append(document) - - merged_documents: list[PCLDocument] = [] - for related_documents in grouped.values(): - if len(related_documents) == 1: - merged_documents.append(related_documents[0]) - continue - merged_documents.append(_merge_related_documents(related_documents)) - return merged_documents - - -def _merge_related_documents(documents: list[PCLDocument]) -> PCLDocument: - """Merge multiple pytest functions that target the same production function.""" - first = documents[0] - merged = PCLDocument.create( - file=first.file, - method=first.method, - source_path=first.source_path, - test_file=_merge_source_labels([document.test_file for document in documents]), - test_method=_merge_source_labels([document.test_method for document in documents]), - test_source_path=_merge_source_labels([document.test_source_path for document in documents]), - ) - merged.generated_at = first.generated_at - - case_idx = 1 - for document in documents: - for case in document.testcases: - merged.testcases.append(replace(case, testcasecode=f"TC-{case_idx:03d}")) - case_idx += 1 - - merged.input_rows = _build_input_rows(merged.testcases) - merged.output_rows = _build_output_rows(merged.testcases) - return merged - - -def _merge_source_labels(values: list[str]) -> str: - """Collapse multiple test source labels into one display string.""" - unique_values = _dedupe_preserve_order(value for value in values if value) - if not unique_values: - return "" - if len(unique_values) == 1: - return unique_values[0] - return f"複数 ({len(unique_values)} 件)" - - def _infer_subject_location( file_path: Path, node: ast.FunctionDef | ast.AsyncFunctionDef, @@ -304,9 +257,7 @@ def _iter_calls_in_order( def _collect_test_files(path: Path) -> list[Path]: """Expand a file or directory input into pytest-style test files.""" - if path.is_file(): - return [path] if _is_test_file(path) else [] - return sorted(file for file in path.rglob("*.py") if _is_test_file(file)) + return discover_files(path, is_candidate=_is_test_file) def _is_test_file(path: Path) -> bool: @@ -315,26 +266,38 @@ def _is_test_file(path: Path) -> bool: return name.startswith("test_") or name.endswith("_test.py") -def _extract_imports(tree: ast.Module) -> dict[str, ImportedSymbol]: +def _extract_imports( + tree: ast.Module, + test_file: Path, +) -> dict[str, ImportedSymbol]: """Map imported names in a test module to resolvable source files.""" imports: dict[str, ImportedSymbol] = {} + search_roots = _import_search_roots(test_file) for node in tree.body: if isinstance(node, ast.Import): for alias in node.names: bind_name = alias.asname or alias.name.split(".")[0] imports[bind_name] = ImportedSymbol( - file_path=_resolve_module_path(alias.name), + file_path=_resolve_module_path(alias.name, search_roots), original_name=alias.name.rsplit(".", 1)[-1], ) if isinstance(node, ast.ImportFrom): module_name = node.module or "" - module_path = _resolve_module_path(module_name) if module_name else None + module_roots = ( + (_relative_import_root(test_file, node.level),) + if node.level + else search_roots + ) + module_path = _resolve_module_path(module_name, module_roots) for alias in node.names: if alias.name == "*": continue bind_name = alias.asname or alias.name symbol_module_name = f"{module_name}.{alias.name}" if module_name else alias.name - file_path = _resolve_module_path(symbol_module_name) or module_path + file_path = ( + _resolve_module_path(symbol_module_name, module_roots) + or module_path + ) imports[bind_name] = ImportedSymbol( file_path=file_path, original_name=alias.name, @@ -343,20 +306,38 @@ def _extract_imports(tree: ast.Module) -> dict[str, ImportedSymbol]: @lru_cache(maxsize=None) -def _resolve_module_path(module_name: str) -> Path | None: - """Resolve an import string to a local module or package path in the workspace.""" - if not module_name: - return None - base_path = Path.cwd() / Path(*module_name.split(".")) - module_file = base_path.with_suffix(".py") - if module_file.exists(): - return module_file - package_init = base_path / "__init__.py" - if package_init.exists(): - return package_init +def _resolve_module_path( + module_name: str, + search_roots: tuple[Path, ...], +) -> Path | None: + """Resolve imports against the tested project instead of TeaForge's process cwd.""" + module_parts = tuple(part for part in module_name.split(".") if part) + for root in search_roots: + base_path = root.joinpath(*module_parts) + module_file = base_path.with_suffix(".py") if module_parts else None + if module_file is not None and module_file.exists(): + return module_file.resolve() + package_init = base_path / "__init__.py" + if package_init.exists(): + return package_init.resolve() return None +def _import_search_roots(test_file: Path) -> tuple[Path, ...]: + """Return deterministic roots that can own absolute imports in the target project.""" + test_parent = test_file.resolve().parent + candidates = [test_parent, *test_parent.parents, Path.cwd().resolve()] + return tuple(dict.fromkeys(candidates)) + + +def _relative_import_root(test_file: Path, level: int) -> Path: + """Resolve the filesystem root represented by an ImportFrom relative level.""" + root = test_file.resolve().parent + for _ in range(max(0, level - 1)): + root = root.parent + return root + + def _resolve_symbol_subject(module_path: Path, symbol_name: str) -> SubjectLocation | None: """Build a subject descriptor only when the target module really defines the symbol.""" if not _module_defines_symbol(module_path, symbol_name): @@ -677,77 +658,6 @@ def _extract_raises_expectation(items: list[ast.withitem]) -> list[str]: return expectations -def _build_input_rows(cases: list[PCLTestCase]) -> list[PCLMatrixRow]: - """Build the PCL input matrix rows from testcase input values.""" - row_hits: OrderedDict[tuple[str, str], dict[str, str]] = OrderedDict() - for case in cases: - for item, value in case.inputs.items(): - key = (item, value) - row_hits.setdefault(key, {}) - row_hits[key][case.testcasecode] = "○" - - rows: list[PCLMatrixRow] = [] - for (item, value), hits in _group_keys_by_item(row_hits).items(): - rows.append( - PCLMatrixRow( - category="input", - item=item, - value=value, - values={case.testcasecode: hits.get(case.testcasecode, "") for case in cases}, - ) - ) - return rows - - -def _build_output_rows(cases: list[PCLTestCase]) -> list[PCLMatrixRow]: - """Build the PCL output matrix rows from testcase assertions.""" - row_hits: OrderedDict[tuple[str, str], dict[str, str]] = OrderedDict() - for case in cases: - checks = case.output_checks or ([case.output] if case.output else []) - for check in checks: - item, value = _split_output_check(check) - key = (item, value) - row_hits.setdefault(key, {}) - row_hits[key][case.testcasecode] = "○" - - rows: list[PCLMatrixRow] = [] - for (item, value), hits in _group_keys_by_item(row_hits).items(): - rows.append( - PCLMatrixRow( - category="output", - item=item, - value=value, - values={case.testcasecode: hits.get(case.testcasecode, "") for case in cases}, - ) - ) - return rows - - -def _split_output_check(check: str) -> tuple[str, str]: - """Split one textual expectation into a matrix item/value pair.""" - if check.startswith("raises "): - return ("raises", check.removeprefix("raises ").strip()) - if " == " in check: - left, right = check.split(" == ", 1) - return (left.strip(), right.strip()) - return ("assertion", check) - - -def _group_keys_by_item( - row_hits: OrderedDict[tuple[str, str], dict[str, str]], -) -> OrderedDict[tuple[str, str], dict[str, str]]: - """Preserve insertion order while grouping matrix rows by item name.""" - item_groups: OrderedDict[str, list[tuple[tuple[str, str], dict[str, str]]]] = OrderedDict() - for key, hits in row_hits.items(): - item_groups.setdefault(key[0], []).append((key, hits)) - - grouped: OrderedDict[tuple[str, str], dict[str, str]] = OrderedDict() - for entries in item_groups.values(): - for key, hits in entries: - grouped[key] = hits - return grouped - - def _evaluate_node(node: ast.AST, context: dict[str, Any]) -> Any: """Evaluate a limited subset of AST nodes into plain Python values.""" if isinstance(node, ast.Constant): @@ -847,6 +757,7 @@ def _to_title(test_name: str) -> str: def _display_path(path: Path) -> str: """Prefer workspace-relative paths when reporting resolved source locations.""" try: - return str(path.relative_to(Path.cwd())) + display_path = path.relative_to(Path.cwd()) except ValueError: - return str(path) + display_path = path + return display_path.as_posix() diff --git a/src/teaforge/pcl/pdf.py b/src/teaforge/pcl/pdf.py index cc52cf5..dd1cd47 100644 --- a/src/teaforge/pcl/pdf.py +++ b/src/teaforge/pcl/pdf.py @@ -2,13 +2,19 @@ from __future__ import annotations +import io +from contextlib import redirect_stderr, redirect_stdout from pathlib import Path def export_pdf_from_html(html_path: Path, output_pdf: Path) -> None: """Export one HTML report to PDF and surface dependency errors as runtime guidance.""" + if not html_path.is_file(): + raise FileNotFoundError(f"Input HTML does not exist: {html_path}") try: - from weasyprint import HTML + diagnostics = io.StringIO() + with redirect_stdout(diagnostics), redirect_stderr(diagnostics): + from weasyprint import HTML except ImportError as exc: raise RuntimeError( "WeasyPrint is required for PDF export. Install with: pip install -e '.[pdf]'" diff --git a/src/teaforge/pcl/render.py b/src/teaforge/pcl/render.py index 1e0b6ef..715ef37 100644 --- a/src/teaforge/pcl/render.py +++ b/src/teaforge/pcl/render.py @@ -2,12 +2,14 @@ from __future__ import annotations -import json +from importlib import resources from pathlib import Path from typing import Any from jinja2 import Environment, FileSystemLoader, select_autoescape +from teaforge.artifacts import json_for_html_script + from .models import PCLDocument CASE_SLOTS_PER_SHEET = 25 @@ -15,19 +17,28 @@ def default_template_path() -> Path: """Return the built-in PCL template path.""" - return Path(__file__).resolve().parents[3] / "templates" / "pcl.html" + return Path(str(resources.files("teaforge.templates").joinpath("pcl.html"))) def render_pcl_html(document: PCLDocument, template_path: Path | None = None) -> str: """Render one PCL document and embed its JSON payload for later lookup.""" - template_file = template_path or default_template_path() - env = Environment( - loader=FileSystemLoader(str(template_file.parent)), - autoescape=select_autoescape(["html", "xml"]), - ) - template = env.get_template(template_file.name) + if template_path is None: + env = Environment(autoescape=select_autoescape(["html", "xml"])) + template = env.from_string( + resources.files("teaforge.templates") + .joinpath("pcl.html") + .read_text(encoding="utf-8") + ) + else: + if not template_path.is_file(): + raise FileNotFoundError(f"PCL template does not exist: {template_path}") + env = Environment( + loader=FileSystemLoader(str(template_path.parent)), + autoescape=select_autoescape(["html", "xml"]), + ) + template = env.get_template(template_path.name) payload = document.to_dict() - embedded_json = json.dumps(payload, ensure_ascii=False, indent=2) + embedded_json = json_for_html_script(payload) case_columns = [ { "index": index, @@ -42,6 +53,11 @@ def render_pcl_html(document: PCLDocument, template_path: Path | None = None) -> "ui_outputs": case.get("ui_outputs", []), "navigation_outputs": case.get("navigation_outputs", []), "verification_mode": case.get("verification_mode", ""), + "test_full_name": case.get("test_full_name", ""), + "assertion_evidence": case.get("assertion_evidence", []), + "execution_status": case.get("execution_status", ""), + "runtime_test_name": case.get("runtime_test_name", ""), + "runtime_test_path": case.get("runtime_test_path", ""), "executed_date": case.get("executed_date", ""), "bug_number": case.get("bug_number", ""), } @@ -64,6 +80,7 @@ def render_pcl_html(document: PCLDocument, template_path: Path | None = None) -> or case["navigation_outputs"] or case["verification_mode"] ] + runtime_cases = [case for case in case_columns if case["assertion_evidence"]] return template.render( document=payload, embedded_json=embedded_json, @@ -72,6 +89,7 @@ def render_pcl_html(document: PCLDocument, template_path: Path | None = None) -> input_rows=input_rows, output_rows=output_rows, frontend_cases=frontend_cases, + runtime_cases=runtime_cases, ) diff --git a/src/teaforge/pcl/service.py b/src/teaforge/pcl/service.py index aed90ea..3c966f2 100644 --- a/src/teaforge/pcl/service.py +++ b/src/teaforge/pcl/service.py @@ -8,7 +8,8 @@ from math import ceil from pathlib import Path -from teaforge.naming import folder_name, slugify +from teaforge.artifacts import write_text_atomic +from teaforge.naming import slugify, source_folder_names from .backends import parse_documents_for_framework from .models import PCLDocument, PCLMatrixRow @@ -23,31 +24,49 @@ def generate_pcl( json_output: Path | None = None, template_path: Path | None = None, framework: str = "pytest", + evidence_mode: str = "static", + runtime_timeout: int = 120, ) -> list[tuple[Path, Path]]: """Generate HTML and JSON outputs for each logical PCL document.""" if not test_path.exists(): raise FileNotFoundError(f"Input path does not exist: {test_path}") - documents = parse_documents_for_framework(test_path, framework) + documents = parse_documents_for_framework( + test_path, + framework, + evidence_mode=evidence_mode, + runtime_timeout=runtime_timeout, + ) sheet_documents = _split_documents(documents, MAX_CASES_PER_SHEET) html_output.parent.mkdir(parents=True, exist_ok=True) - outputs: list[tuple[Path, Path]] = [] + prepared_outputs: list[tuple[Path, Path, str, str]] = [] multiple_outputs = len(sheet_documents) > 1 + source_folders = source_folder_names( + Path(document.source_path) for document in sheet_documents + ) for document in sheet_documents: html_path, json_path = _resolve_output_paths( document=document, base_html=html_output, base_json=json_output, multiple_outputs=multiple_outputs, + output_folder=source_folders[Path(document.source_path).resolve()], ) html_content = render_pcl_html(document, template_path=template_path) - html_path.write_text(html_content, encoding="utf-8") - json_path.parent.mkdir(parents=True, exist_ok=True) - json_path.write_text( - json.dumps(document.to_dict(), ensure_ascii=False, indent=2), - encoding="utf-8", + json_content = json.dumps( + document.to_dict(), + ensure_ascii=False, + indent=2, + ) + prepared_outputs.append( + (html_path, json_path, html_content, json_content) ) + + outputs: list[tuple[Path, Path]] = [] + for html_path, json_path, html_content, json_content in prepared_outputs: + write_text_atomic(html_path, html_content) + write_text_atomic(json_path, json_content) outputs.append((html_path, json_path)) return outputs @@ -105,6 +124,11 @@ def _split_documents(documents: list[PCLDocument], chunk_size: int) -> list[PCLD test_method=document.test_method, test_source_path=document.test_source_path, generated_at=document.generated_at, + schema_version=document.schema_version, + evidence_mode=document.evidence_mode, + evidence_warnings=list(document.evidence_warnings), + runtime_exit_code=document.runtime_exit_code, + runtime_tests_passed=document.runtime_tests_passed, sheet_number=sheet_number, sheet_count=total_sheets, testcases=[replace(case) for case in cases], @@ -138,6 +162,7 @@ def _resolve_output_paths( base_html: Path, base_json: Path | None, multiple_outputs: bool, + output_folder: str, ) -> tuple[Path, Path]: """Resolve the HTML and JSON output paths for one generated PCL document.""" # Multi-document runs are grouped by production file so one tested module owns one folder. @@ -147,12 +172,12 @@ def _resolve_output_paths( json_path.parent.mkdir(parents=True, exist_ok=True) return html_path, json_path - output_dir = base_html.parent / folder_name(Path(document.file).stem) + output_dir = base_html.parent / output_folder output_dir.mkdir(parents=True, exist_ok=True) suffix = _build_output_suffix(document) html_path = output_dir / f"{base_html.stem}-{suffix}{base_html.suffix}" if base_json is not None: - json_dir = base_json.parent / folder_name(Path(document.file).stem) + json_dir = base_json.parent / output_folder json_dir.mkdir(parents=True, exist_ok=True) json_path = json_dir / f"{base_json.stem}-{suffix}{base_json.suffix}" else: @@ -202,5 +227,14 @@ def _format_case_description(case) -> str: lines.append(f"遷移確認: {'; '.join(case.navigation_outputs)}") if getattr(case, "verification_mode", ""): lines.append(f"確認方法: {case.verification_mode}") + if getattr(case, "assertion_evidence", None): + lines.append(f"実行結果: {case.execution_status}") + for evidence in case.assertion_evidence: + expected = ", ".join(evidence.expected) or "(none)" + lines.append( + "実行証拠: " + f"{evidence.matcher} expected={expected} actual={evidence.actual} " + f"passed={str(evidence.passed).lower()}" + ) lines.append(f"期待結果: {case.output}") return "\n".join(lines) diff --git a/src/teaforge/playwright/parser.py b/src/teaforge/playwright/parser.py index 8899e26..fd0ee68 100644 --- a/src/teaforge/playwright/parser.py +++ b/src/teaforge/playwright/parser.py @@ -6,10 +6,18 @@ from pathlib import Path from urllib.parse import parse_qsl, urlsplit -from teaforge.jest.parser import JestTestBlock, _collect_test_files, _extract_test_blocks, _find_matching, _parse_literal_expression, _stringify_value +from teaforge.jest.parser import ( + JestTestBlock, + _collect_test_files, + _extract_test_blocks, + _find_matching, + _parse_literal_expression, + _stringify_value, +) +from teaforge.pcl.assembly import build_input_rows, build_output_rows, merge_documents_by_subject from teaforge.pcl.classification import classify_type from teaforge.pcl.models import PCLDocument, PCLTestCase -from teaforge.pcl.parser import _build_input_rows, _build_output_rows, _display_path, _merge_documents_by_subject +from teaforge.pcl.parser import _display_path def parse_playwright_documents(test_path: Path) -> list[PCLDocument]: @@ -21,13 +29,20 @@ def parse_playwright_documents(test_path: Path) -> list[PCLDocument]: raw_documents: list[PCLDocument] = [] for file_path in files: content = file_path.read_text(encoding="utf-8") - for index, block in enumerate(_extract_test_blocks(content), start=1): + for index, block in enumerate( + _extract_test_blocks( + content, + suffix=file_path.suffix, + source_name=str(file_path), + ), + start=1, + ): raw_documents.append(_build_document_for_block(file_path, block, index)) if not raw_documents: raise ValueError(f"No supported Playwright tests found under: {test_path}") - return _merge_documents_by_subject(raw_documents) + return merge_documents_by_subject(raw_documents) def _build_document_for_block(file_path: Path, block: JestTestBlock, index: int) -> PCLDocument: @@ -61,10 +76,11 @@ def _build_document_for_block(file_path: Path, block: JestTestBlock, index: int) navigation_outputs=navigation_outputs, verification_mode="playwright", output_checks=output_checks, + test_full_name=block.full_name or block.title, ) ) - document.input_rows = _build_input_rows(document.testcases) - document.output_rows = _build_output_rows(document.testcases) + document.input_rows = build_input_rows(document.testcases) + document.output_rows = build_output_rows(document.testcases) return document diff --git a/src/teaforge/process.py b/src/teaforge/process.py new file mode 100644 index 0000000..c8807f3 --- /dev/null +++ b/src/teaforge/process.py @@ -0,0 +1,454 @@ +"""Run external tools with bounded output, deadlines, and process-tree cleanup.""" + +from __future__ import annotations + +import os +import re +import signal +import subprocess +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, Mapping, Sequence + +DEFAULT_PROCESS_DETAIL_LIMIT = 8_000 +DEFAULT_PROCESS_OUTPUT_LIMIT = 4 * 1024 * 1024 +DEFAULT_TERMINATE_GRACE_SECONDS = 2.0 +_OUTPUT_MARKER_RESERVE = 128 +_ANSI_ESCAPE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))") + + +@dataclass(slots=True, frozen=True) +class ProcessResult: + """One completed external process with memory-bounded captured streams.""" + + args: tuple[str, ...] + returncode: int + stdout: str + stderr: str + duration_seconds: float + stdout_truncated: bool = False + stderr_truncated: bool = False + + +class ProcessTimeoutError(RuntimeError): + """A workflow deadline expired after TeaForge cleaned up the process tree.""" + + code = "process-timeout" + + def __init__( + self, + *, + operation: str, + timeout_seconds: float, + result: ProcessResult, + ) -> None: + self.operation = operation + self.timeout_seconds = timeout_seconds + self.result = result + super().__init__(f"{operation} timed out after {timeout_seconds:g} seconds.") + + +@dataclass(slots=True, frozen=True) +class WorkflowDeadline: + """Share one monotonic timeout budget across every process in a workflow.""" + + operation: str + timeout_seconds: float + started_at: float + _clock: Callable[[], float] = field(repr=False, compare=False) + + @classmethod + def start( + cls, + operation: str, + timeout_seconds: float, + *, + clock: Callable[[], float] | None = None, + ) -> "WorkflowDeadline": + if timeout_seconds <= 0: + raise ValueError("Workflow timeout must be greater than zero seconds.") + selected_clock = clock or time.monotonic + return cls( + operation=operation, + timeout_seconds=timeout_seconds, + started_at=selected_clock(), + _clock=selected_clock, + ) + + def remaining_seconds(self) -> float: + """Return the remaining budget or fail before another process is started.""" + elapsed = max(self._clock() - self.started_at, 0.0) + remaining = self.timeout_seconds - elapsed + if remaining > 0: + return remaining + raise ProcessTimeoutError( + operation=self.operation, + timeout_seconds=self.timeout_seconds, + result=ProcessResult( + args=(), + returncode=-1, + stdout="", + stderr="", + duration_seconds=elapsed, + ), + ) + + +class _BoundedByteCapture: + """Drain a byte stream while retaining only a diagnostic head and tail.""" + + def __init__(self, limit: int) -> None: + if limit < 1024: + raise ValueError("Process output limit must be at least 1024 bytes.") + storage_limit = limit - _OUTPUT_MARKER_RESERVE + self._limit = limit + self._head_limit = storage_limit // 3 + self._tail_limit = storage_limit - self._head_limit + self._head = bytearray() + self._tail = bytearray() + self._total = 0 + + @property + def truncated(self) -> bool: + return self._total > self._head_limit + self._tail_limit + + def feed(self, chunk: bytes) -> None: + if not chunk: + return + self._total += len(chunk) + head_space = self._head_limit - len(self._head) + if head_space > 0: + self._head.extend(chunk[:head_space]) + chunk = chunk[head_space:] + if chunk: + self._tail.extend(chunk) + if len(self._tail) > self._tail_limit: + del self._tail[: len(self._tail) - self._tail_limit] + + def text(self) -> str: + if not self.truncated: + captured = bytes(self._head + self._tail) + else: + omitted = self._total - len(self._head) - len(self._tail) + marker = f"\n... [process output truncated {omitted} bytes] ...\n".encode() + captured = bytes(self._head) + marker + bytes(self._tail) + return ( + captured[: self._limit] + .decode("utf-8", errors="replace") + .replace("\r\n", "\n") + .replace("\r", "\n") + ) + + +def run_process( + command: Sequence[str | os.PathLike[str]], + *, + operation: str, + timeout_seconds: float, + cwd: Path | str | None = None, + environment: Mapping[str, str] | None = None, + output_limit: int = DEFAULT_PROCESS_OUTPUT_LIMIT, + terminate_grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS, +) -> ProcessResult: + """Run one command without a shell and clean up its process tree on timeout.""" + if not command: + raise ValueError("Process command must not be empty.") + if timeout_seconds <= 0: + raise ValueError("Process timeout must be greater than zero seconds.") + if terminate_grace_seconds < 0: + raise ValueError("Process terminate grace period must not be negative.") + + normalized_command = tuple(os.fspath(part) for part in command) + stdout_capture = _BoundedByteCapture(output_limit) + stderr_capture = _BoundedByteCapture(output_limit) + popen_options: dict[str, object] = {} + if os.name == "nt": + popen_options["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_options["start_new_session"] = True + + started = time.monotonic() + process = subprocess.Popen( + normalized_command, + cwd=os.fspath(cwd) if cwd is not None else None, + env=dict(environment) if environment is not None else None, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + **popen_options, + ) + job_handle = _assign_windows_kill_job(process) if os.name == "nt" else None + readers = ( + threading.Thread( + target=_drain_stream, + args=(process.stdout, stdout_capture), + name="teaforge-stdout", + daemon=True, + ), + threading.Thread( + target=_drain_stream, + args=(process.stderr, stderr_capture), + name="teaforge-stderr", + daemon=True, + ), + ) + for reader in readers: + reader.start() + + timed_out = False + deadline = started + timeout_seconds + try: + process.wait(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + timed_out = True + if not timed_out: + for reader in readers: + reader.join(timeout=max(deadline - time.monotonic(), 0.0)) + timed_out = any(reader.is_alive() for reader in readers) + + if timed_out: + _terminate_process_tree(process, job_handle) + _wait_for_exit(process, terminate_grace_seconds) + for reader in readers: + reader.join(timeout=terminate_grace_seconds) + if process.poll() is None or any(reader.is_alive() for reader in readers): + _kill_process_tree(process, job_handle) + _wait_for_exit(process, terminate_grace_seconds) + for stream in (process.stdout, process.stderr): + if stream is not None and not stream.closed: + stream.close() + for reader in readers: + reader.join(timeout=terminate_grace_seconds) + + _close_windows_job(job_handle) + + result = ProcessResult( + args=normalized_command, + returncode=process.returncode, + stdout=stdout_capture.text(), + stderr=stderr_capture.text(), + duration_seconds=time.monotonic() - started, + stdout_truncated=stdout_capture.truncated, + stderr_truncated=stderr_capture.truncated, + ) + if timed_out: + raise ProcessTimeoutError( + operation=operation, + timeout_seconds=timeout_seconds, + result=result, + ) + return result + + +def _drain_stream(stream, capture: _BoundedByteCapture) -> None: + if stream is None: + return + try: + while True: + chunk = stream.read(64 * 1024) + if not chunk: + return + capture.feed(chunk) + finally: + stream.close() + + +def _wait_for_exit(process: subprocess.Popen[bytes], timeout_seconds: float) -> None: + if process.poll() is not None: + return + try: + process.wait(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + pass + + +def _terminate_process_tree(process: subprocess.Popen[bytes], job_handle) -> None: + if os.name == "nt": + if job_handle is not None: + _terminate_windows_job(job_handle, exit_code=1) + return + if process.poll() is not None: + return + if _taskkill_windows_tree(process.pid): + return + try: + process.terminate() + except OSError: + pass + return + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + + +def _kill_process_tree(process: subprocess.Popen[bytes], job_handle) -> None: + if os.name == "nt" and job_handle is not None: + _terminate_windows_job(job_handle, exit_code=1) + return + if os.name == "nt": + if process.poll() is not None: + return + if _taskkill_windows_tree(process.pid): + return + try: + process.kill() + except OSError: + pass + return + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + + +def _taskkill_windows_tree(process_id: int) -> bool: + """Use Windows' built-in tree termination when Job Object assignment is unavailable.""" + if os.name != "nt": + return False + try: + cleanup = subprocess.Popen( + ["taskkill", "/PID", str(process_id), "/T", "/F"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + return cleanup.wait(timeout=5) == 0 + except (OSError, subprocess.TimeoutExpired): + return False + + +def _assign_windows_kill_job(process: subprocess.Popen[bytes]): + """Assign a Windows process to a kill-on-close Job Object when available.""" + if os.name != "nt": + return None + try: + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR] + kernel32.CreateJobObjectW.restype = wintypes.HANDLE + kernel32.SetInformationJobObject.argtypes = [ + wintypes.HANDLE, + ctypes.c_int, + ctypes.c_void_p, + wintypes.DWORD, + ] + kernel32.SetInformationJobObject.restype = wintypes.BOOL + kernel32.AssignProcessToJobObject.argtypes = [ + wintypes.HANDLE, + wintypes.HANDLE, + ] + kernel32.AssignProcessToJobObject.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + job = kernel32.CreateJobObjectW(None, None) + if not job: + return None + + class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("PerProcessUserTimeLimit", ctypes.c_longlong), + ("PerJobUserTimeLimit", ctypes.c_longlong), + ("LimitFlags", wintypes.DWORD), + ("MinimumWorkingSetSize", ctypes.c_size_t), + ("MaximumWorkingSetSize", ctypes.c_size_t), + ("ActiveProcessLimit", wintypes.DWORD), + ("Affinity", ctypes.c_size_t), + ("PriorityClass", wintypes.DWORD), + ("SchedulingClass", wintypes.DWORD), + ] + + class IO_COUNTERS(ctypes.Structure): + _fields_ = [(name, ctypes.c_ulonglong) for name in ( + "ReadOperationCount", + "WriteOperationCount", + "OtherOperationCount", + "ReadTransferCount", + "WriteTransferCount", + "OtherTransferCount", + )] + + class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION), + ("IoInfo", IO_COUNTERS), + ("ProcessMemoryLimit", ctypes.c_size_t), + ("JobMemoryLimit", ctypes.c_size_t), + ("PeakProcessMemoryUsed", ctypes.c_size_t), + ("PeakJobMemoryUsed", ctypes.c_size_t), + ] + + information = JOBOBJECT_EXTENDED_LIMIT_INFORMATION() + information.BasicLimitInformation.LimitFlags = 0x00002000 + configured = kernel32.SetInformationJobObject( + job, + 9, + ctypes.byref(information), + ctypes.sizeof(information), + ) + assigned = kernel32.AssignProcessToJobObject(job, int(process._handle)) + if not configured or not assigned: + kernel32.CloseHandle(job) + return None + return job + except Exception: + return None + + +def _close_windows_job(job_handle) -> None: + if os.name != "nt" or job_handle is None: + return + try: + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + kernel32.CloseHandle(job_handle) + except Exception: + pass + + +def _terminate_windows_job(job_handle, *, exit_code: int) -> None: + if os.name != "nt" or job_handle is None: + return + try: + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT] + kernel32.TerminateJobObject.restype = wintypes.BOOL + kernel32.TerminateJobObject( + job_handle, + exit_code, + ) + except Exception: + pass + + +def bounded_process_detail( + stderr: str | None, + stdout: str | None, + *, + limit: int = DEFAULT_PROCESS_DETAIL_LIMIT, +) -> str: + """Select stderr-first diagnostics, remove terminal control text, and cap size.""" + if limit < 128: + raise ValueError("Process detail limit must be at least 128 characters.") + detail = (stderr or stdout or "").strip().replace("\x00", "") + detail = _ANSI_ESCAPE.sub("", detail) + if len(detail) <= limit: + return detail + + marker = f"\n... [truncated {len(detail) - limit} characters] ...\n" + remaining = max(limit - len(marker), 2) + head_length = remaining // 3 + tail_length = remaining - head_length + return f"{detail[:head_length]}{marker}{detail[-tail_length:]}" diff --git a/src/teaforge/templates/__init__.py b/src/teaforge/templates/__init__.py new file mode 100644 index 0000000..4b2ed4b --- /dev/null +++ b/src/teaforge/templates/__init__.py @@ -0,0 +1 @@ +"""Bundled HTML templates for TeaForge reports.""" diff --git a/src/teaforge/templates/coverage_report.html b/src/teaforge/templates/coverage_report.html new file mode 100644 index 0000000..ecde7ab --- /dev/null +++ b/src/teaforge/templates/coverage_report.html @@ -0,0 +1,392 @@ + + + + + + + {{ document.title }} - {{ document.file }} + + + + +
+
+

{{ document.title }}

+
Design by TeaForge.🍵
+
+ + + + + + + + + + + + + + + + + + + + + + +
対象ファイル{{ document.file }}ページ1 / {{ document.page_count }}
ソース{{ document.source_path }}テスト入力{{ document.test_path }}
生成日時{{ document.generated_at }}証拠ソース{{ document.evidence_source }}
+ + + + + + + + + + + + +
C0 定義{{ document.c0_definition }}
C1 定義{{ document.c1_definition }}
+ +
カバレッジ概要
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
指標割合CoveredTotalMissing
C0 (行カバレッジ){% if document.c0.applicable %}{{ "%.1f"|format(document.c0.percent) }}%{% else %}N/A{% endif %}{{ document.c0.covered }}{{ document.c0.total }}{{ document.c0.missing }}
C1 (分岐カバレッジ){% if document.c1.applicable %}{{ "%.1f"|format(document.c1.percent) }}%{% else %}N/A{% endif %}{{ document.c1.covered }}{{ document.c1.total }}{{ document.c1.missing }}
+ +
関数別ページ一覧
+ + + + + + + + + + + + + + {% for function in document.functions %} + + + + + + + + + + {% endfor %} + +
No.関数行範囲C0C1ページMermaid
{{ loop.index }}{{ function.name }}{{ function.lineno }} - {{ function.end_lineno }}{% if function.c0.applicable %}{{ "%.1f"|format(function.c0.percent) }}%{% else %}N/A{% endif %}{% if function.c1.applicable %}{{ "%.1f"|format(function.c1.percent) }}%{% else %}N/A{% endif %} + {% if function.diagrams %} + {% for diagram in function.diagrams %}{{ diagram.page_number }}{% if not loop.last %}, {% endif %}{% endfor %} + {% elif function.page_number %}{{ function.page_number }}{% else %}-{% endif %} + + {% if function.diagrams %} + {% for diagram in function.diagrams %}{{ diagram.kind }}: {{ diagram.mermaid_path }}{% if not loop.last %}
{% endif %}{% endfor %} + {% elif function.diagram_included %}{{ function.mermaid_path }}{% else %}Not requested{% endif %} +
+ + {% if not document.requested_functions %} +
業務図
+ + + + + + + +
状態このレポートはカバレッジ概要のみです。必要な業務関数だけ `--function` を指定してフロー図を、`--sequence` を指定してシーケンス図を追加してください。
+ {% endif %} + + +
+ + {% for function in document.functions %} + {% for diagram in function.diagrams %} + {% if diagram.kind == "sequence" %} +
+ {% else %} +
+ {% endif %} +
+

{{ document.title }} - {{ function.name.split('.')[-1] }}

+
Design by TeaForge.🍵
+
+ + + + + + + + + + + + + + + + + + + + + + +
対象ファイル{{ document.file }}ページ{{ diagram.page_number }} / {{ document.page_count }}
関数{{ function.name }}行範囲{{ function.lineno }} - {{ function.end_lineno }}
Mermaid{{ diagram.mermaid_path }}SVG{{ diagram.svg_path }}
+ +
関数別カバレッジ
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
指標割合CoveredTotalMissing
C0 (行カバレッジ){% if function.c0.applicable %}{{ "%.1f"|format(function.c0.percent) }}%{% else %}N/A{% endif %}{{ function.c0.covered }}{{ function.c0.total }}{{ function.c0.missing }}
C1 (分岐カバレッジ){% if function.c1.applicable %}{{ "%.1f"|format(function.c1.percent) }}%{% else %}N/A{% endif %}{{ function.c1.covered }}{{ function.c1.total }}{{ function.c1.missing }}
+ +
未カバー詳細
+ + + + + + + + + + + +
未カバー行 + {% if function.missing_lines %} + {{ function.missing_lines|join(", ") }} + {% else %} + なし + {% endif %} +
未カバー分岐 + {% if function.missing_branches %} + {{ function.missing_branches|join(", ") }} + {% else %} + なし + {% endif %} +
+ +
{% if diagram.kind == "sequence" %}シーケンス図{% else %}ロジックフロー図{% endif %}
+
+ {{ function.name }} {{ diagram.kind }} diagram +
+ + +
+ {% endfor %} + {% endfor %} + + + + + diff --git a/src/teaforge/templates/pcl.html b/src/teaforge/templates/pcl.html new file mode 100644 index 0000000..5b00223 --- /dev/null +++ b/src/teaforge/templates/pcl.html @@ -0,0 +1,460 @@ + + + + + + + {{ document.title }} + + + + +
+
+

{{ document.title }}

+
Design by TeaForge.🍵
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + {% if document.runtime_exit_code is not none %} + + + + + + + {% endif %} + +
対象ファイル{{ document.file }}対象メソッド{{ document.method }}
生成元{{ document.source_path }}シート{{ document.sheet_number }} / {{ document.sheet_count }}
生成日時{{ document.generated_at }}
証拠モード{{ document.evidence_mode }}警告{{ document.evidence_warnings|join("; ") }}
Jest 終了コード{{ document.runtime_exit_code }}テスト実行{% if document.runtime_tests_passed %}PASS{% else %}FAIL{% endif %}
+ +
テストケース一覧
+ + + + + + + + + + + + + + {% for case in case_columns %} + + + + + + + + + + {% endfor %} + +
番号コード区分テストケーステスト名実施日Bug No.
{{ case.index }}{{ case.testcasecode }}{{ case.type }}{{ case.testcase }}{{ case.testname }}{{ case.executed_date }}{{ case.bug_number }}
+ + {% if runtime_cases %} +
Jest 実行証拠
+ + + + + + + + + + + + + + {% for case in runtime_cases %} + {% for evidence in case.assertion_evidence %} + + + + + + + + + + {% endfor %} + {% endfor %} + +
コード実行テスト名状態Matcher期待値実際値エラー
{{ case.testcasecode }}{{ case.runtime_test_name }} + {% if evidence.passed %}PASS{% else %}FAIL{% endif %} + {% if evidence.negated %}not.{% endif %}{{ evidence.matcher }}{{ evidence.expected|join(", ") }}{{ evidence.actual }}{{ evidence.error }}
+ {% endif %} + + {% if frontend_cases %} +
前端ページUT補足
+ + + + + + + + + + + + + + + + {% for case in frontend_cases %} + + + + + + + + + + + + {% endfor %} + +
番号コード画面名入口URL確認方法前提条件操作入力画面出力遷移出力
{{ case.index }}{{ case.testcasecode }}{{ case.screen_name }}{{ case.entry_url }}{% if case.verification_mode %}{{ case.verification_mode }}{% endif %} + {% if case.preconditions %} +
    + {% for item in case.preconditions %} +
  • {{ item }}
  • + {% endfor %} +
+ {% endif %} +
+ {% if case.input_actions %} +
    + {% for item in case.input_actions %} +
  • {{ item }}
  • + {% endfor %} +
+ {% endif %} +
+ {% if case.ui_outputs %} +
    + {% for item in case.ui_outputs %} +
  • {{ item }}
  • + {% endfor %} +
+ {% endif %} +
+ {% if case.navigation_outputs %} +
    + {% for item in case.navigation_outputs %} +
  • {{ item }}
  • + {% endfor %} +
+ {% endif %} +
+ {% endif %} + +
判定表
+ + + + + {% for case in decision_columns %} + + {% endfor %} + + + + {% for row in input_rows %} + + {% if loop.first %} + + {% endif %} + {% if row.show_item %} + + {% endif %} + + {% for case in decision_columns %} + + {% endfor %} + + {% endfor %} + + {% for row in output_rows %} + + {% if loop.first %} + + {% endif %} + {% if row.show_item %} + + {% endif %} + + {% for case in decision_columns %} + + {% endfor %} + + {% endfor %} + +
テスト項目番号{{ loop.index }}
条件{{ row.item }}{{ row.value }}{% if case and row["values"].get(case.testcasecode) %}◯{% endif %}
結果{{ row.item }}{{ row.value }}{% if case and row["values"].get(case.testcasecode) %}◯{% endif %}
+ +
記入欄
+ + + + + + + + + + + + + + + +
実施日担当者
レビュー備考
+ + +
+ + + + + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..46701aa --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,2 @@ +# This directory is an external target-project fixture with its own pytest root. +collect_ignore = ["fixtures/coverage_project/tests/test_app.py"] diff --git a/tests/fixtures/coverage_project/app.py b/tests/fixtures/coverage_project/app.py new file mode 100644 index 0000000..8f777bc --- /dev/null +++ b/tests/fixtures/coverage_project/app.py @@ -0,0 +1,2 @@ +def double(value: int) -> int: + return value * 2 diff --git a/tests/fixtures/coverage_project/pyproject.toml b/tests/fixtures/coverage_project/pyproject.toml new file mode 100644 index 0000000..80abd51 --- /dev/null +++ b/tests/fixtures/coverage_project/pyproject.toml @@ -0,0 +1,6 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.coverage.run] +relative_files = true +source = ["unrelated_target"] diff --git a/tests/fixtures/coverage_project/tests/test_app.py b/tests/fixtures/coverage_project/tests/test_app.py new file mode 100644 index 0000000..58fb70b --- /dev/null +++ b/tests/fixtures/coverage_project/tests/test_app.py @@ -0,0 +1,5 @@ +from app import double + + +def test_double(): + assert double(3) == 6 diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py new file mode 100644 index 0000000..2ba2d81 --- /dev/null +++ b/tests/test_artifacts.py @@ -0,0 +1,44 @@ +from pathlib import Path + +import pytest + +import teaforge.artifacts as artifacts_module +from teaforge.artifacts import json_for_html_script, write_text_atomic + + +def test_json_for_html_script_preserves_data_without_closing_script_element(): + serialized = json_for_html_script( + {"test": "", "separator": "\u2028"} + ) + + assert "" not in serialized + assert "\\u003c/script\\u003e" in serialized + assert "\\u2028" in serialized + + +def test_atomic_write_replaces_complete_artifact(tmp_path): + target = tmp_path / "report.json" + target.write_text("old", encoding="utf-8") + + write_text_atomic(target, "new content") + + assert target.read_text(encoding="utf-8") == "new content" + assert list(tmp_path.glob(".report.json.*.tmp")) == [] + + +def test_atomic_write_preserves_old_artifact_when_replace_fails( + tmp_path, monkeypatch +): + target = tmp_path / "report.html" + target.write_text("valid old report", encoding="utf-8") + + def fail_replace(source: Path, destination: Path) -> None: + raise OSError("disk refused replacement") + + monkeypatch.setattr(artifacts_module.os, "replace", fail_replace) + + with pytest.raises(OSError, match="disk refused replacement"): + write_text_atomic(target, "partial new report") + + assert target.read_text(encoding="utf-8") == "valid old report" + assert list(tmp_path.glob(".report.html.*.tmp")) == [] diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py index 386f489..2f18272 100644 --- a/tests/test_cli_integration.py +++ b/tests/test_cli_integration.py @@ -1,12 +1,27 @@ +import json from pathlib import Path +import pytest from typer.testing import CliRunner +from teaforge import __version__ from teaforge.cli import app +from teaforge.coverage.service import ( + CoverageThresholdError, + CoverageThresholdViolation, +) +from teaforge.pcl.models import PCLDocument runner = CliRunner() +def test_version_option_matches_package_version(): + result = runner.invoke(app, ["--version"]) + + assert result.exit_code == 0 + assert result.stdout.strip() == __version__ + + def test_generate_and_get_commands(tmp_path): html_path = tmp_path / "pcl.html" source = Path("tests/fixtures/test_sample_pytest.py") @@ -170,3 +185,141 @@ def test_generate_pcl_rejects_unknown_framework(tmp_path): assert result.exit_code == 1 assert "Unsupported framework: unknown" in result.stderr + + +def test_generate_pcl_reports_missing_custom_template_without_traceback(tmp_path): + missing_template = tmp_path / "missing-template.html" + + result = runner.invoke( + app, + [ + "pcl", + "generate", + "--path", + "tests/fixtures/test_sample_pytest.py", + "--output", + str(tmp_path / "pcl.html"), + "--template", + str(missing_template), + ], + ) + + assert result.exit_code == 1 + assert "PCL template does not exist" in result.stderr + assert "Traceback" not in result.stderr + + +def test_runtime_report_returns_distinct_exit_code_when_jest_tests_failed( + tmp_path, monkeypatch +): + html_path = tmp_path / "pcl.html" + json_path = tmp_path / "pcl.json" + document = PCLDocument.create( + file="user.js", + method="createUser", + source_path="src/user.js", + ) + document.evidence_mode = "runtime" + document.runtime_exit_code = 1 + document.runtime_tests_passed = False + html_path.write_text("", encoding="utf-8") + json_path.write_text( + json.dumps(document.to_dict(), ensure_ascii=False), + encoding="utf-8", + ) + monkeypatch.setattr( + "teaforge.cli.generate_pcl", + lambda **kwargs: [(html_path, json_path)], + ) + + result = runner.invoke( + app, + [ + "pcl", + "generate", + "--framework", + "jest", + "--evidence-mode", + "runtime", + "--path", + "tests/fixtures/test_sample_jest.test.ts", + "--output", + str(html_path), + ], + ) + + assert result.exit_code == 2 + assert "Reports were generated with failure evidence" in result.stderr + + accepted = runner.invoke( + app, + [ + "pcl", + "generate", + "--framework", + "jest", + "--evidence-mode", + "runtime", + "--allow-test-failures", + "--path", + "tests/fixtures/test_sample_jest.test.ts", + "--output", + str(html_path), + ], + ) + + assert accepted.exit_code == 0 + + +def test_pcl_document_rejects_unknown_future_schema(): + document = PCLDocument.create( + file="user.py", + method="create_user", + source_path="src/user.py", + ) + payload = document.to_dict() + payload["schema_version"] = 999 + + with pytest.raises(ValueError, match="Unsupported PCL schema version: 999"): + PCLDocument.from_dict(payload) + + +def test_coverage_gate_preserves_report_and_returns_exit_code_three( + tmp_path, monkeypatch +): + output = tmp_path / "coverage.html" + output.write_text("coverage report", encoding="utf-8") + + def fail_gate(**_kwargs): + raise CoverageThresholdError( + [output], + [ + CoverageThresholdViolation( + output_path=output, + metric="C1", + actual=75.0, + required=100.0, + ) + ], + ) + + monkeypatch.setattr("teaforge.cli.generate_coverage_reports", fail_gate) + + result = runner.invoke( + app, + [ + "coverage", + "generate", + "--path", + "demo/fastapi_crud/tests", + "--output", + str(output), + "--min-c1", + "100", + ], + ) + + assert result.exit_code == 3 + assert output.exists() + assert f"Generated coverage HTML: {output}" in result.stdout + assert "C1 75.0% is below the required 100.0%" in result.stderr diff --git a/tests/test_coverage_report.py b/tests/test_coverage_report.py index d496b16..7d320ab 100644 --- a/tests/test_coverage_report.py +++ b/tests/test_coverage_report.py @@ -7,13 +7,193 @@ from typer.testing import CliRunner from teaforge.cli import app +from teaforge.coverage import mermaid as mermaid_module from teaforge.coverage import service as coverage_service -from teaforge.coverage.analyzer import parse_source_functions +from teaforge.coverage.analyzer import analyze_coverage from teaforge.coverage.mermaid import save_mermaid_diagram +from teaforge.coverage.models import CoverageDocument, CoverageMetric +from teaforge.coverage.render import render_coverage_html +from teaforge.coverage.service import find_coverage_threshold_violations +from teaforge.jest.coverage import _collect_branch_sets +from teaforge.jest.project import JestProject runner = CliRunner() +def test_coverage_embedded_json_cannot_close_its_script_element(): + document = CoverageDocument.create( + file=".py", + source_path="src/app.py", + test_path="tests/test_app.py", + c0=CoverageMetric(covered=1, total=1, percent=100.0), + c1=CoverageMetric(covered=0, total=0, percent=0.0), + requested_functions=[], + functions=[], + evidence_source="coverage.py JSON", + c0_definition="covered statements / measurable statements", + c1_definition="executed branches / measurable branches", + ) + + html = render_coverage_html(document) + embedded_json = html.split( + '", 1)[0] + + assert "