From 0640fd3e9949a7edcef86f7747b0663ebdcc537d Mon Sep 17 00:00:00 2001 From: XYZ1024-alt Date: Mon, 13 Jul 2026 10:53:14 +0800 Subject: [PATCH] fix: harden trace semantics and project quality --- .github/workflows/python-runtime-matrix.yml | 87 +++- AGENTS.md | 31 ++ package-lock.json | 457 ++++++++++---------- package.json | 11 +- playwright.config.ts | 2 +- scripts/check-dev-env.mjs | 64 ++- scripts/run-backend-tests.mjs | 127 ++++-- src-tauri/Cargo.lock | 2 +- src-tauri/src/error.rs | 18 +- src-tauri/src/python_runner.rs | 50 +++ src-tauri/src/python_value.rs | 174 +++++++- src-tauri/src/trace_worker.rs | 6 +- src-tauri/src/tracer.rs | 99 +++-- src/App.tsx | 1 + src/flowModel.test.ts | 21 + src/flowModel.ts | 14 +- src/formatValue.test.ts | 93 ++++ src/formatValue.ts | 71 ++- src/player.css | 6 +- src/useTraceSession.test.ts | 78 ++++ src/useTraceSession.ts | 39 +- src/valuePreview.ts | 93 +++- tests/e2e/visualization.spec.ts | 34 ++ 23 files changed, 1233 insertions(+), 345 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/useTraceSession.test.ts diff --git a/.github/workflows/python-runtime-matrix.yml b/.github/workflows/python-runtime-matrix.yml index 0d15cd6..b68206b 100644 --- a/.github/workflows/python-runtime-matrix.yml +++ b/.github/workflows/python-runtime-matrix.yml @@ -1,4 +1,4 @@ -name: Python Runtime Matrix +name: Quality and Python Runtime Matrix on: push: @@ -11,6 +11,79 @@ permissions: contents: read jobs: + quality-and-bundle: + name: Frontend, Rust quality, and Windows bundle + runs-on: windows-latest + timeout-minutes: 45 + env: + CARGO_TERM_COLOR: always + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + architecture: x64 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target + shared-key: windows-quality + + - name: Install frontend dependencies + run: npm ci + + - name: Verify development environment + run: npm run doctor + + - name: Build frontend assets + run: npm run build + + - name: Test frontend units + run: npm test + + - name: Install Playwright Chromium + run: npx playwright install chromium + + - name: Test browser flows + run: npm run test:e2e + + - name: Upload Playwright failure traces + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-failure-traces + path: test-results + if-no-files-found: ignore + retention-days: 7 + + - name: Audit production dependencies + run: npm audit --registry=https://registry.npmjs.org --omit=dev + + - name: Check Rust formatting + run: cargo fmt --manifest-path src-tauri/Cargo.toml --check + + - name: Lint Rust targets + run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings + + - name: Build Windows Tauri bundle + run: npx tauri build --bundles nsis + windows-python-runtime: name: Windows Python ${{ matrix.python-version }} runs-on: windows-latest @@ -36,7 +109,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: 22 - cache: npm - name: Setup Python uses: actions/setup-python@v5 @@ -56,20 +128,11 @@ jobs: - name: Verify selected Python runtime shell: pwsh run: | - python --version + npm run doctor where.exe python where.exe python3.dll python -c "import platform, sys; print(sys.version); print(platform.architecture()[0])" - - name: Install frontend dependencies - run: npm ci - - - name: Build frontend assets - run: npm run build - - - name: Compile Rust test binaries - run: cargo test --manifest-path src-tauri/Cargo.toml --no-run - - name: Test Rust backend run: npm run test:backend diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..27387f1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +PyWeave is a Tauri desktop application with a React/TypeScript frontend and a Rust/PyO3 backend. Frontend components and domain logic live in `src/`; colocate Vitest files as `*.test.ts`. Native commands, Python tracing, policy enforcement, and inline Rust tests live in `src-tauri/src/`. Playwright scenarios belong in `tests/e2e/`, developer utilities in `scripts/`, and Tauri capabilities and application icons under `src-tauri/capabilities/` and `src-tauri/icons/`. Treat `dist/`, `target/`, `test-results/`, and `node_modules/` as generated output. + +## Build, Test, and Development Commands + +- `npm install`: install JavaScript dependencies (`npm ci` in clean/CI environments). +- `npm run doctor`: verify Node.js, npm, Rust/Cargo, and Python 3 prerequisites. +- `npm run dev`: run the Vite frontend at `127.0.0.1:1420`. +- `npm run dev:desktop`: launch the complete Tauri application. +- `npm run build`: type-check with `tsc`, then build frontend assets. +- `npm run tauri -- build`: produce desktop bundles under `src-tauri/target/`. +- `npm run test:all`: run the build, Vitest, Playwright, and Rust test suites. + +## Coding Style & Naming Conventions + +TypeScript is strict and targets ES2022. Follow the existing style: two-space indentation, double quotes, semicolons, trailing commas, `PascalCase` React components, `camelCase` functions/variables, and `UPPER_SNAKE_CASE` constants. Rust uses standard `rustfmt` formatting, four-space indentation, `snake_case` functions/modules, and `CamelCase` types. Keep tracing and policy logic separate from UI state; prefer immutable inputs and explicit errors. No ESLint or Prettier command is configured, so do not claim formatting checks that do not exist. + +## Testing Guidelines + +Use behavior-focused Vitest `describe`/`it` names and colocate unit tests with their modules. Name browser tests `*.spec.ts`; keep Rust unit tests in `#[cfg(test)]` modules with descriptive `snake_case` names. Run `npm test`, `npm run test:e2e`, or `npm run test:backend` for focused checks. There is no coverage threshold; new behavior should still receive regression coverage. Backend tests enforce a 60-second timeout, and CI validates Python 3.10-3.13 on Windows. + +## Commit & Pull Request Guidelines + +Follow the repository's Conventional Commit pattern: `type: concise imperative summary`, for example `fix: support Python 3.10 runtime`. Common types are `feat`, `fix`, `test`, `ci`, and `chore`. Keep commits focused. Pull requests should explain behavior and architecture changes, list verification commands, link relevant issues, and include screenshots for UI changes. + +## Security & Configuration + +PyWeave executes Python locally. Keep restricted execution enabled by default; set `PYWEAVE_ALLOW_UNRESTRICTED_PYTHON=1` only for trusted local code. Never commit credentials or weaken worker timeout, snapshot-size, or trace-limit protections without documenting the risk. diff --git a/package-lock.json b/package-lock.json index 79a8fdf..3430e81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,20 +20,20 @@ "@tauri-apps/cli": "^2.9.0", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", - "@vitejs/plugin-react": "^5.1.0", + "@vitejs/plugin-react": "^5.2.0", "typescript": "^5.9.3", - "vite": "^7.2.0", + "vite": "^7.3.6", "vitest": "^4.1.8" } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "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.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -42,9 +42,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "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": { @@ -52,21 +52,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "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.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@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", @@ -83,14 +83,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "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.0", - "@babel/types": "^7.29.0", + "@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" @@ -100,14 +100,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "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.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@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" @@ -117,9 +117,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "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": { @@ -127,29 +127,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "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.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "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.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -169,9 +169,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "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": { @@ -179,9 +179,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "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": { @@ -189,9 +189,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "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": { @@ -199,27 +199,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "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.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "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.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -261,33 +261,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "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.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@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.0", - "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "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.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@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": { @@ -295,23 +295,23 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "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.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -326,9 +326,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -343,9 +343,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -360,9 +360,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -377,9 +377,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -394,9 +394,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -411,9 +411,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -428,9 +428,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -445,9 +445,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -462,9 +462,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -479,9 +479,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -496,9 +496,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -513,9 +513,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -530,9 +530,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -547,9 +547,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -564,9 +564,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -581,9 +581,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -598,9 +598,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -615,9 +615,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -632,9 +632,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -649,9 +649,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -666,9 +666,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -683,9 +683,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -700,9 +700,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -717,9 +717,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -734,9 +734,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1579,7 +1579,7 @@ }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", - "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", "dev": true, "license": "MIT", @@ -1754,9 +1754,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.30", - "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz", - "integrity": "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==", + "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": { @@ -1767,9 +1767,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", "dev": true, "funding": [ { @@ -1787,10 +1787,10 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", + "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": { @@ -1801,9 +1801,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001805", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", + "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", "dev": true, "funding": [ { @@ -1958,7 +1958,7 @@ }, "node_modules/debug": { "version": "4.4.3", - "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", @@ -1975,9 +1975,9 @@ } }, "node_modules/dompurify": { - "version": "3.2.7", - "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.2.7.tgz", - "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", "license": "(MPL-2.0 OR Apache-2.0)", "peer": true, "optionalDependencies": { @@ -1985,9 +1985,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.357", - "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", - "integrity": "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==", + "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" }, @@ -1999,9 +1999,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2012,37 +2012,37 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { "version": "3.2.0", - "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", @@ -2115,14 +2115,14 @@ }, "node_modules/js-tokens": { "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, "license": "MIT" }, "node_modules/jsesc": { "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "license": "MIT", @@ -2148,7 +2148,7 @@ }, "node_modules/lru-cache": { "version": "5.1.1", - "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", @@ -2201,7 +2201,7 @@ }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" @@ -2226,11 +2226,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.44", - "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.44.tgz", - "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "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" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/obug": { "version": "2.1.2", @@ -2433,7 +2436,7 @@ }, "node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", @@ -2538,7 +2541,7 @@ }, "node_modules/update-browserslist-db": { "version": "1.2.3", - "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "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": [ @@ -2577,13 +2580,13 @@ } }, "node_modules/vite": { - "version": "7.3.3", - "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.3.tgz", - "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -2760,7 +2763,7 @@ }, "node_modules/yallist": { "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" diff --git a/package.json b/package.json index 54e36ef..44e6ac3 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dev:desktop": "tauri dev", "build": "tsc && vite build", "preview": "vite preview", - "test": "vitest run src\\flowModel.test.ts src\\formatValue.test.ts src\\teaching.test.ts src\\traceSemantics.test.ts", + "test": "vitest run src", "test:backend": "node scripts/run-backend-tests.mjs", "test:e2e": "node scripts/run-e2e.mjs", "test:all": "npm run build && npm test && npm run test:e2e && npm run test:backend", @@ -24,14 +24,19 @@ "react": "^19.2.0", "react-dom": "^19.2.0" }, + "overrides": { + "@babel/core": "7.29.7", + "dompurify": "3.4.12", + "esbuild": "0.28.1" + }, "devDependencies": { "@playwright/test": "^1.60.0", "@tauri-apps/cli": "^2.9.0", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", - "@vitejs/plugin-react": "^5.1.0", + "@vitejs/plugin-react": "^5.2.0", "typescript": "^5.9.3", - "vite": "^7.2.0", + "vite": "^7.3.6", "vitest": "^4.1.8" } } diff --git a/playwright.config.ts b/playwright.config.ts index 5a5635a..ce82723 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ reporter: [["list"]], use: { baseURL: "http://127.0.0.1:1420", - trace: "on-first-retry", + trace: "retain-on-failure", }, projects: [ { diff --git a/scripts/check-dev-env.mjs b/scripts/check-dev-env.mjs index 4584484..eed27e6 100644 --- a/scripts/check-dev-env.mjs +++ b/scripts/check-dev-env.mjs @@ -8,11 +8,20 @@ const NPM_CANDIDATES = [ ]; const REQUIRED_TOOLS = [ - { name: "Node.js", candidates: [{ command: "node", args: ["--version"] }] }, + { + name: "Node.js", + minimumVersion: "20.19.0", + candidates: [{ command: "node", args: ["--version"] }], + }, { name: "npm", candidates: NPM_CANDIDATES }, - { name: "Rust cargo", candidates: [{ command: "cargo", args: ["--version"] }] }, + { + name: "Rust cargo", + minimumVersion: "1.85.0", + candidates: [{ command: "cargo", args: ["--version"] }], + }, { name: "Python 3", + minimumVersion: "3.10.0", candidates: [ { command: "python", args: ["--version"] }, { command: "python3", args: ["--version"] }, @@ -33,23 +42,36 @@ if (missing.length > 0) { } for (const result of results) { - console.log(`✓ ${result.name}: ${result.message}`); + console.log(`OK ${result.name}: ${result.message}`); } function checkTool(tool) { + const foundVersions = []; + for (const candidate of tool.candidates) { - const version = commandVersion(candidate.command, candidate.args); + const output = commandVersion(candidate.command, candidate.args); + if (!output) { + continue; + } - if (version) { - return { ok: true, name: tool.name, message: version }; + const version = parseVersion(output); + foundVersions.push(output); + if (!tool.minimumVersion || (version && compareVersions(version, tool.minimumVersion) >= 0)) { + return { ok: true, name: tool.name, message: output }; } } - return { - ok: false, - name: tool.name, - message: `none of ${tool.candidates.map((candidate) => candidate.command).join(", ")} worked`, - }; + const message = requirementFailure(tool, foundVersions); + return { ok: false, name: tool.name, message }; +} + +function requirementFailure(tool, foundVersions) { + if (foundVersions.length > 0) { + return `requires >= ${tool.minimumVersion}; found ${foundVersions.join(", ")}`; + } + + const commands = tool.candidates.map((candidate) => candidate.command).join(", "); + return `none of ${commands} worked`; } function commandVersion(command, args) { @@ -62,9 +84,23 @@ function commandVersion(command, args) { return null; } - return versionOutput(result); + return (result.stdout || result.stderr).trim(); } -function versionOutput(result) { - return (result.stdout || result.stderr).trim(); +function parseVersion(output) { + return output.match(/\d+\.\d+\.\d+/)?.[0] ?? null; +} + +function compareVersions(actual, minimum) { + const actualParts = actual.split(".").map(Number); + const minimumParts = minimum.split(".").map(Number); + + for (let index = 0; index < minimumParts.length; index += 1) { + const difference = actualParts[index] - minimumParts[index]; + if (difference !== 0) { + return difference; + } + } + + return 0; } diff --git a/scripts/run-backend-tests.mjs b/scripts/run-backend-tests.mjs index cac6baa..66881dc 100644 --- a/scripts/run-backend-tests.mjs +++ b/scripts/run-backend-tests.mjs @@ -1,31 +1,104 @@ -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; const BACKEND_TEST_TIMEOUT_MS = 60_000; -const CARGO_ARGS = ["test", "--manifest-path", "src-tauri\\Cargo.toml"]; - -const child = spawn("cargo", CARGO_ARGS, { - stdio: "inherit", - windowsHide: true, -}); - -const timeout = setTimeout(() => { - console.error(`cargo test exceeded ${BACKEND_TEST_TIMEOUT_MS} ms; terminating backend tests`); - child.kill(); -}, BACKEND_TEST_TIMEOUT_MS); - -child.on("error", (error) => { - clearTimeout(timeout); - console.error(error.message); - process.exit(1); -}); - -child.on("exit", (code, signal) => { - clearTimeout(timeout); - - if (signal) { - console.error(`cargo test terminated by ${signal}`); - process.exit(1); +const REPOSITORY_ROOT = fileURLToPath(new URL("..", import.meta.url)); +const MANIFEST_PATH = path.join(REPOSITORY_ROOT, "src-tauri", "Cargo.toml"); +const CARGO_TEST_ARGS = ["test", "--manifest-path", MANIFEST_PATH]; + +try { + console.log("Compiling Rust backend tests (compile time is not part of the test timeout)..."); + await runCargo({ + args: [...CARGO_TEST_ARGS, "--no-run"], + stage: "Rust backend test compilation", + }); + + console.log(`Running Rust backend tests (hard timeout: ${BACKEND_TEST_TIMEOUT_MS} ms)...`); + await runCargo({ + args: CARGO_TEST_ARGS, + stage: "Rust backend test execution", + timeoutMs: BACKEND_TEST_TIMEOUT_MS, + }); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} + +function runCargo({ args, stage, timeoutMs }) { + return new Promise((resolve, reject) => { + const child = spawn("cargo", args, { + cwd: REPOSITORY_ROOT, + detached: process.platform !== "win32", + stdio: "inherit", + windowsHide: true, + }); + let settled = false; + let timedOut = false; + const timeout = timeoutMs + ? setTimeout(() => { + timedOut = true; + try { + terminateProcessTree(child.pid); + } catch (error) { + finish(() => reject(new Error(`${stage} timed out but termination failed: ${error.message}`))); + } + }, timeoutMs) + : null; + + child.once("error", (error) => { + finish(() => reject(new Error(`${stage} could not start cargo: ${error.message}`))); + }); + child.once("close", (code, signal) => finish(() => { + if (timedOut) { + reject(new Error(`${stage} exceeded ${timeoutMs} ms and was terminated`)); + } else if (signal) { + reject(new Error(`${stage} was terminated by ${signal}`)); + } else if (code !== 0) { + reject(new Error(`${stage} exited with code ${code ?? "unknown"}`)); + } else { + resolve(); + } + })); + + function finish(callback) { + if (settled) { + return; + } + + settled = true; + if (timeout) { + clearTimeout(timeout); + } + callback(); + } + }); +} + +function terminateProcessTree(pid) { + if (!pid) { + return; } - process.exit(code ?? 1); -}); + if (process.platform === "win32") { + const result = spawnSync("taskkill", ["/pid", String(pid), "/t", "/f"], { + encoding: "utf8", + windowsHide: true, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error((result.stderr || result.stdout).trim() || `taskkill exited with code ${result.status}`); + } + return; + } + + try { + process.kill(-pid, "SIGKILL"); + } catch (error) { + if (error?.code !== "ESRCH") { + throw error; + } + } +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 430074d..4dd032c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2374,7 +2374,7 @@ dependencies = [ [[package]] name = "pyweave-tauri" -version = "0.2.0" +version = "0.2.2" dependencies = [ "pyo3", "serde", diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 904f2e2..37b0562 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -3,6 +3,8 @@ use pyo3::prelude::*; use pyo3::types::{PyAnyMethods, PyTracebackMethods, PyTypeMethods}; use serde::{Deserialize, Serialize}; +use crate::tracer::TRACE_LIMIT_LINE_MARKER; + const TRACE_FILE_MARKER: &str = "File \"\", line "; const POLICY_LINE_MARKER: &str = "PyWeave policy rejected line "; @@ -18,8 +20,9 @@ impl TraceExecutionError { let kind = error_kind(py, &error); let message = error_message(py, &error); let line = syntax_line(error.value(py)) + .or_else(|| marked_line(&message, TRACE_LIMIT_LINE_MARKER)) .or_else(|| traceback_line(py, &error)) - .or_else(|| policy_line(&message)); + .or_else(|| marked_line(&message, POLICY_LINE_MARKER)); Self { kind, @@ -72,8 +75,8 @@ fn parse_trace_line(line: &str) -> Option { digits.parse().ok() } -fn policy_line(message: &str) -> Option { - let start = message.find(POLICY_LINE_MARKER)? + POLICY_LINE_MARKER.len(); +fn marked_line(message: &str, marker: &str) -> Option { + let start = message.find(marker)? + marker.len(); let tail = &message[start..]; let digits: String = tail .chars() @@ -86,7 +89,7 @@ fn policy_line(message: &str) -> Option { mod tests { use pyo3::{Python, types::PyAnyMethods}; - use super::TraceExecutionError; + use super::{TRACE_LIMIT_LINE_MARKER, TraceExecutionError, marked_line}; #[test] fn formats_syntax_error_line() { @@ -105,4 +108,11 @@ mod tests { assert!(formatted.message.contains("invalid syntax")); }); } + + #[test] + fn parses_pending_line_from_trace_limit_error() { + let message = format!("{TRACE_LIMIT_LINE_MARKER}37: maximum 1000 frames"); + + assert_eq!(marked_line(&message, TRACE_LIMIT_LINE_MARKER), Some(37)); + } } diff --git a/src-tauri/src/python_runner.rs b/src-tauri/src/python_runner.rs index 10aab8d..157c198 100644 --- a/src-tauri/src/python_runner.rs +++ b/src-tauri/src/python_runner.rs @@ -123,6 +123,42 @@ done = items ); } + #[test] + fn attributes_locals_to_the_line_that_produced_them() { + let source = "first = 1\nsecond = first + 1\nthird = second + 1\n"; + + let trace_run = run_python_trace(source).expect("assignments should trace"); + let first = frame_at_line(&trace_run.frames, 1); + let second = frame_at_line(&trace_run.frames, 2); + let third = frame_at_line(&trace_run.frames, 3); + + assert_eq!(first.locals.len(), 1); + assert_eq!(first.locals.get("first"), Some(&json!(1))); + assert_eq!(second.locals.len(), 2); + assert_eq!(second.locals.get("first"), Some(&json!(1))); + assert_eq!(second.locals.get("second"), Some(&json!(2))); + assert_eq!(third.locals.get("third"), Some(&json!(3))); + assert_eq!(third.event, "return"); + } + + #[test] + fn settles_nested_scope_before_resuming_the_caller() { + let source = "outer = 1\ndef increment(value):\n result = value + 1\n return result\nanswer = increment(outer)\nafter = answer + 1\n"; + + let trace_run = run_python_trace(source).expect("nested call should trace"); + let function_line = frame_at_line(&trace_run.frames, 3); + let return_line = frame_at_line(&trace_run.frames, 4); + let caller_line = frame_at_line(&trace_run.frames, 5); + let function_index = frame_index_at_line(&trace_run.frames, 3); + let caller_index = frame_index_at_line(&trace_run.frames, 5); + + assert_eq!(function_line.scope_name, "increment"); + assert_eq!(function_line.locals.get("result"), Some(&json!(2))); + assert_eq!(return_line.return_value, Some(json!(2))); + assert_eq!(caller_line.locals.get("answer"), Some(&json!(2))); + assert!(function_index < caller_index); + } + #[test] fn returns_source_lines_and_frame_metadata() { let source = @@ -256,4 +292,18 @@ while True: assert!(error.to_string().contains("Trace snapshot limit exceeded")); }); } + + fn frame_at_line( + frames: &[crate::tracer::TraceFrame], + line: usize, + ) -> &crate::tracer::TraceFrame { + &frames[frame_index_at_line(frames, line)] + } + + fn frame_index_at_line(frames: &[crate::tracer::TraceFrame], line: usize) -> usize { + frames + .iter() + .position(|frame| frame.line == line) + .unwrap_or_else(|| panic!("missing frame for line {line}")) + } } diff --git a/src-tauri/src/python_value.rs b/src-tauri/src/python_value.rs index 4d1641a..6085ee4 100644 --- a/src-tauri/src/python_value.rs +++ b/src-tauri/src/python_value.rs @@ -13,6 +13,11 @@ const PREVIEW_TAIL_DIVISOR: usize = 3; const PREVIEW_MARKER: &str = "__pyweavePreview"; const PREVIEW_SEQUENCE: &str = "sequence"; const PREVIEW_MAPPING: &str = "mapping"; +const EXACT_MARKER: &str = "__pyweaveExact"; +const EXACT_SEQUENCE: &str = "sequence"; +const EXACT_MAPPING: &str = "mapping"; +const DICT_TYPE: &str = "dict"; +const TUPLE_TYPE: &str = "tuple"; #[derive(Clone, Debug)] pub struct ValueConversionLimits { @@ -91,7 +96,13 @@ fn sequence_value<'py>( let values: PyResult> = (0..len) .map(|index| get_item(index).and_then(|item| to_json_value_with_limits(&item, limits))) .collect(); - values.map(Value::Array) + let values = values?; + + if type_name == TUPLE_TYPE { + return Ok(exact_sequence_value(type_name, values)); + } + + Ok(Value::Array(values)) } fn mapping_value(dict: &Bound<'_, PyDict>, limits: &ValueConversionLimits) -> PyResult { @@ -108,7 +119,7 @@ fn mapping_value(dict: &Bound<'_, PyDict>, limits: &ValueConversionLimits) -> Py ); } - Ok(Value::Object(output)) + Ok(exact_mapping_value(output)) } fn sequence_preview<'py>( @@ -181,6 +192,32 @@ fn preview_base(kind: &str, type_name: &str, len: usize) -> Map { output } +fn exact_sequence_value(type_name: &str, items: Vec) -> Value { + let mut output = Map::new(); + + output.insert( + EXACT_MARKER.to_owned(), + Value::String(EXACT_SEQUENCE.to_owned()), + ); + output.insert("typeName".to_owned(), Value::String(type_name.to_owned())); + output.insert("items".to_owned(), Value::Array(items)); + + Value::Object(output) +} + +fn exact_mapping_value(entries: Map) -> Value { + let mut output = Map::new(); + + output.insert( + EXACT_MARKER.to_owned(), + Value::String(EXACT_MAPPING.to_owned()), + ); + output.insert("typeName".to_owned(), Value::String(DICT_TYPE.to_owned())); + output.insert("entries".to_owned(), Value::Object(entries)); + + Value::Object(output) +} + fn preview_item_limit(limits: &ValueConversionLimits, len: usize) -> Option { limits .preview_items @@ -211,3 +248,136 @@ fn preview_items_limit() -> PyResult> { fn usize_value(value: usize) -> Value { Value::Number(Number::from(value as u64)) } + +#[cfg(test)] +mod tests { + use pyo3::{ffi::c_str, prelude::*}; + use serde_json::json; + + use super::{ValueConversionLimits, to_json_value_with_limits}; + + #[test] + fn preserves_tuple_type_and_nested_python_values() { + Python::attach(|py| { + let value = py + .eval( + c_str!("([None, True, {'name': 'Ada'}], ('only',), (1, False))"), + None, + None, + ) + .expect("test expression should evaluate"); + let limits = ValueConversionLimits { + preview_items: Some(96), + }; + + let converted = to_json_value_with_limits(&value, &limits) + .expect("supported Python values should convert"); + + assert_eq!( + converted, + json!({ + "__pyweaveExact": "sequence", + "typeName": "tuple", + "items": [ + [ + null, + true, + { + "__pyweaveExact": "mapping", + "typeName": "dict", + "entries": {"name": "Ada"} + } + ], + { + "__pyweaveExact": "sequence", + "typeName": "tuple", + "items": ["only"] + }, + { + "__pyweaveExact": "sequence", + "typeName": "tuple", + "items": [1, false] + } + ] + }) + ); + }); + } + + #[test] + fn keeps_exact_lists_as_json_arrays() { + Python::attach(|py| { + let value = py + .eval(c_str!("[1, 2, 3]"), None, None) + .expect("test expression should evaluate"); + let limits = ValueConversionLimits { + preview_items: Some(96), + }; + + let converted = to_json_value_with_limits(&value, &limits) + .expect("supported Python values should convert"); + + assert_eq!(converted, json!([1, 2, 3])); + }); + } + + #[test] + fn wraps_dicts_that_look_like_protocol_values() { + Python::attach(|py| { + let value = py + .eval( + c_str!("{'__pyweaveExact': 'sequence', 'typeName': 'tuple', 'items': [1]}"), + None, + None, + ) + .expect("test expression should evaluate"); + let limits = ValueConversionLimits { + preview_items: Some(96), + }; + + let converted = to_json_value_with_limits(&value, &limits) + .expect("supported Python values should convert"); + + assert_eq!( + converted, + json!({ + "__pyweaveExact": "mapping", + "typeName": "dict", + "entries": { + "__pyweaveExact": "sequence", + "typeName": "tuple", + "items": [1] + } + }) + ); + }); + } + + #[test] + fn keeps_large_tuples_as_sequence_previews() { + Python::attach(|py| { + let value = py + .eval(c_str!("(None, 1, 2, 3, True)"), None, None) + .expect("test expression should evaluate"); + let limits = ValueConversionLimits { + preview_items: Some(2), + }; + + let converted = to_json_value_with_limits(&value, &limits) + .expect("supported Python values should convert"); + + assert_eq!( + converted, + json!({ + "__pyweavePreview": "sequence", + "typeName": "tuple", + "length": 5, + "truncated": true, + "head": [null], + "tailStart": 4, + "tail": [true] + }) + ); + }); + } +} diff --git a/src-tauri/src/trace_worker.rs b/src-tauri/src/trace_worker.rs index 3d81c40..4ffac6c 100644 --- a/src-tauri/src/trace_worker.rs +++ b/src-tauri/src/trace_worker.rs @@ -1,7 +1,7 @@ use std::env; use std::fs::{self, File}; use std::io::{self, Read, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Child, Command, ExitStatus, Stdio}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -270,12 +270,12 @@ fn temp_file_path(suffix: &str) -> Result { ))) } -fn read_output_file(path: &PathBuf) -> Result { +fn read_output_file(path: &Path) -> Result { fs::read_to_string(path) .map_err(|error| TraceExecutionError::new("WorkerProcessError", error.to_string())) } -fn report_cleanup_error(result: io::Result<()>, path: &PathBuf) { +fn report_cleanup_error(result: io::Result<()>, path: &Path) { if let Err(error) = result { eprintln!( "failed to remove trace worker file {}: {error}", diff --git a/src-tauri/src/tracer.rs b/src-tauri/src/tracer.rs index 4dbfa31..58ac279 100644 --- a/src-tauri/src/tracer.rs +++ b/src-tauri/src/tracer.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::env; use pyo3::exceptions::PyRuntimeError; @@ -10,6 +10,7 @@ use serde_json::Value; use crate::python_value::{ValueConversionLimits, to_json_value_with_limits}; pub const MAX_TRACE_EVENTS: usize = 1000; +pub const TRACE_LIMIT_LINE_MARKER: &str = "Trace snapshot limit exceeded at line "; const DEFAULT_MAX_SNAPSHOT_BYTES: usize = 262_144; const SNAPSHOT_LIMIT_ENV: &str = "PYWEAVE_MAX_SNAPSHOT_BYTES"; const CAPTURED_EVENTS: &[&str] = &["line", "return"]; @@ -44,16 +45,24 @@ pub struct TraceRun { #[pyclass] pub struct TraceCollector { frames: Vec, + pending_lines: HashMap, source_lines: Vec, target_filename: String, } +struct PendingLine { + line: usize, + scope_name: String, + call_depth: usize, +} + #[pymethods] impl TraceCollector { #[new] pub fn new(target_filename: &str, source: &str) -> Self { Self { frames: Vec::new(), + pending_lines: HashMap::new(), source_lines: source_lines(source), target_filename: target_filename.to_owned(), } @@ -69,35 +78,82 @@ impl TraceCollector { return Ok(()); } - if self.frames.len() >= MAX_TRACE_EVENTS { - return Err(PyRuntimeError::new_err(format!( - "Trace snapshot limit exceeded: {MAX_TRACE_EVENTS} frames" - ))); + let frame_id = frame.as_ptr() as usize; + match event { + "line" => self.record_line(frame_id, frame), + "return" => self.record_return(frame_id, frame, arg), + _ => Ok(()), } + } +} - let locals = copy_locals(frame)?; - let return_value = return_value(event, arg)?; - enforce_snapshot_size(&locals, &return_value)?; +impl TraceCollector { + pub fn frames(&self) -> &[TraceFrame] { + &self.frames + } + + fn record_line(&mut self, frame_id: usize, frame: &Bound<'_, PyAny>) -> PyResult<()> { + self.settle_pending(frame_id, frame, "line", None)?; let line = frame.getattr("f_lineno")?.extract()?; + self.pending_lines.insert( + frame_id, + PendingLine { + line, + scope_name: scope_name(frame)?, + call_depth: call_depth(frame, &self.target_filename)?, + }, + ); + Ok(()) + } + + fn record_return( + &mut self, + frame_id: usize, + frame: &Bound<'_, PyAny>, + arg: &Bound<'_, PyAny>, + ) -> PyResult<()> { + let return_value = Some(visualizable_value( + arg, + &ValueConversionLimits::from_environment()?, + )?); + self.settle_pending(frame_id, frame, "return", return_value) + } + + fn settle_pending( + &mut self, + frame_id: usize, + frame: &Bound<'_, PyAny>, + event: &str, + return_value: Option, + ) -> PyResult<()> { + let Some(pending) = self.pending_lines.remove(&frame_id) else { + return Ok(()); + }; + self.ensure_event_capacity(pending.line)?; + let locals = copy_locals(frame)?; + enforce_snapshot_size(&locals, &return_value)?; self.frames.push(TraceFrame { step: self.frames.len(), event: event.to_owned(), - line, - line_text: self.line_text(line), - scope_name: scope_name(frame)?, - call_depth: call_depth(frame, &self.target_filename)?, + line: pending.line, + line_text: self.line_text(pending.line), + scope_name: pending.scope_name, + call_depth: pending.call_depth, locals, return_value, }); - Ok(()) } -} -impl TraceCollector { - pub fn frames(&self) -> &[TraceFrame] { - &self.frames + fn ensure_event_capacity(&self, pending_line: usize) -> PyResult<()> { + if self.frames.len() < MAX_TRACE_EVENTS { + return Ok(()); + } + + Err(PyRuntimeError::new_err(format!( + "{TRACE_LIMIT_LINE_MARKER}{pending_line}: maximum {MAX_TRACE_EVENTS} frames" + ))) } fn should_capture(&self, frame: &Bound<'_, PyAny>, event: &str) -> PyResult { @@ -192,15 +248,6 @@ fn unsupported_value(value: &Bound<'_, PyAny>, error: &PyErr) -> PyResult Ok(Value::String(format!(""))) } -fn return_value(event: &str, arg: &Bound<'_, PyAny>) -> PyResult> { - if event != "return" { - return Ok(None); - } - - let limits = ValueConversionLimits::from_environment()?; - Ok(Some(visualizable_value(arg, &limits)?)) -} - fn enforce_snapshot_size( locals: &BTreeMap, return_value: &Option, diff --git a/src/App.tsx b/src/App.tsx index a7ecd6f..cfcf6e4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -103,6 +103,7 @@ function useTemplateCode(trace: TraceSession) { function handleCodeChange(nextCode: string) { setPythonCode(nextCode); + trace.reset(); if (selectedTemplateId !== CUSTOM_TEMPLATE.id) { setSelectedTemplateId(CUSTOM_TEMPLATE.id); diff --git a/src/flowModel.test.ts b/src/flowModel.test.ts index 4aa7c97..4c2bf6f 100644 --- a/src/flowModel.test.ts +++ b/src/flowModel.test.ts @@ -60,4 +60,25 @@ describe("createFlowModel", () => { expect(pointerData?.pointers).toEqual(["mid"]); expect(pointerData?.value).toBe(""); }); + + it("visualizes exact tuples as indexed array value nodes", () => { + const tuple = { + __pyweaveExact: "sequence", + typeName: "tuple", + items: [3, true, null], + } as const; + const model = createFlowModel({ tuple }, undefined, 1); + + expect(model.nodes.map((node) => node.id)).toEqual(["tuple-0", "tuple-1", "tuple-2"]); + expect(model.nodes.map((node) => node.type)).toEqual([ + "arrayValue", + "arrayValue", + "arrayValue", + ]); + expect(model.nodes.map((node) => (node.data as ArrayNodeData).value)).toEqual([ + 3, + true, + null, + ]); + }); }); diff --git a/src/flowModel.ts b/src/flowModel.ts index 22fa5c9..ec227fa 100644 --- a/src/flowModel.ts +++ b/src/flowModel.ts @@ -1,6 +1,6 @@ import type { FlowEdge, FlowNode, JsonObject, JsonValue, VariableChange } from "./types"; -import { isSequencePreview, sequencePreviewCell } from "./valuePreview"; -import type { PreviewCell, SequencePreview } from "./valuePreview"; +import { isExactSequence, isSequencePreview, sequencePreviewCell } from "./valuePreview"; +import type { ExactSequence, PreviewCell, SequencePreview } from "./valuePreview"; const ARRAY_NODE_SPACING = 80; const ARRAY_NODE_WIDTH = 64; @@ -34,7 +34,7 @@ type FlowModel = { readonly changes: VariableChange[]; }; -type ArrayLikeValue = readonly JsonValue[] | SequencePreview; +type ArrayLikeValue = readonly JsonValue[] | ExactSequence | SequencePreview; type ArrayEntry = { readonly variable: string; @@ -245,15 +245,19 @@ function arrayCell(values: ArrayLikeValue, index: number): PreviewCell { return sequencePreviewCell(values, index); } + if (isExactSequence(values)) { + return { known: index in values.items, value: values.items[index] }; + } + return { known: index in values, value: values[index] }; } function arrayLength(values: ArrayLikeValue): number { - return values.length; + return isExactSequence(values) ? values.items.length : values.length; } function isArrayLikeValue(value: JsonValue): value is ArrayLikeValue { - return Array.isArray(value) || isSequencePreview(value); + return Array.isArray(value) || isExactSequence(value) || isSequencePreview(value); } function createLabelNode( diff --git a/src/formatValue.test.ts b/src/formatValue.test.ts index 6895005..e062868 100644 --- a/src/formatValue.test.ts +++ b/src/formatValue.test.ts @@ -3,6 +3,71 @@ import { describe, expect, it } from "vitest"; import { formatValue } from "./formatValue"; describe("formatValue", () => { + it("uses Python representations for primitive values", () => { + expect(formatValue(null)).toBe("None"); + expect(formatValue(true)).toBe("True"); + expect(formatValue(false)).toBe("False"); + expect(formatValue("hello\nworld")).toBe('"hello\\nworld"'); + }); + + it("formats nested containers recursively", () => { + const value = [null, true, { name: "Ada", flags: [false] }] as const; + + expect(formatValue(value)).toBe('[None, True, {"name": "Ada", "flags": [False]}]'); + }); + + it("preserves exact tuple syntax, including the single-item comma", () => { + const value = { + __pyweaveExact: "sequence", + typeName: "tuple", + items: ["only"], + } as const; + + expect(formatValue(value)).toBe('(\"only\",)'); + }); + + it("formats containers nested inside exact tuples recursively", () => { + const value = { + __pyweaveExact: "sequence", + typeName: "tuple", + items: [[true], { + __pyweaveExact: "mapping", + typeName: "dict", + entries: { value: null }, + }], + } as const; + + expect(formatValue(value)).toBe('([True], {"value": None})'); + }); + + it("does not confuse a protocol-shaped Python dict with a tuple", () => { + const value = { + __pyweaveExact: "mapping", + typeName: "dict", + entries: { + __pyweaveExact: "sequence", + typeName: "tuple", + items: [1], + }, + } as const; + + expect(formatValue(value)).toBe( + '{"__pyweaveExact": "sequence", "typeName": "tuple", "items": [1]}', + ); + }); + + it("does not accept malformed exact sequence tags", () => { + const value = { + __pyweaveExact: "sequence", + typeName: "list", + items: [true], + } as const; + + expect(formatValue(value)).toBe( + '{"__pyweaveExact": "sequence", "typeName": "list", "items": [True]}', + ); + }); + it("marks long values as truncated previews", () => { const value = Array.from({ length: 200 }, (_, index) => index); @@ -22,4 +87,32 @@ describe("formatValue", () => { expect(formatValue(value)).toBe("list(len=100, preview=[0, 1, ..., 99])"); }); + + it("formats tuple previews with Python tuple delimiters", () => { + const value = { + __pyweavePreview: "sequence", + typeName: "tuple", + length: 5, + head: [null], + tailStart: 4, + tail: [true], + truncated: true, + } as const; + + expect(formatValue(value)).toBe("tuple(len=5, preview=(None, ..., True))"); + }); + + it("formats nested mapping preview values with Python representations", () => { + const value = { + __pyweavePreview: "mapping", + typeName: "dict", + length: 100, + entries: { enabled: true, missing: null }, + truncated: true, + } as const; + + expect(formatValue(value)).toBe( + 'dict(len=100, preview={"enabled": True, "missing": None})', + ); + }); }); diff --git a/src/formatValue.ts b/src/formatValue.ts index c9c9d6c..eabc6a4 100644 --- a/src/formatValue.ts +++ b/src/formatValue.ts @@ -1,6 +1,16 @@ import type { JsonValue } from "./types"; -import { isMappingPreview, isSequencePreview } from "./valuePreview"; -import type { MappingPreview, SequencePreview } from "./valuePreview"; +import { + isExactMapping, + isExactSequence, + isMappingPreview, + isSequencePreview, +} from "./valuePreview"; +import type { + ExactMapping, + ExactSequence, + MappingPreview, + SequencePreview, +} from "./valuePreview"; const MAX_FORMATTED_VALUE_CHARS = 240; const PREVIEW_ITEM_SEPARATOR = ", "; @@ -17,7 +27,23 @@ export function formatValue(value: JsonValue): string { function formatRawValue(value: JsonValue): string { if (value === null) { - return "null"; + return "None"; + } + + if (typeof value === "boolean") { + return value ? "True" : "False"; + } + + if (typeof value === "string") { + return JSON.stringify(value); + } + + if (isExactMapping(value)) { + return formatExactMapping(value); + } + + if (isExactSequence(value)) { + return formatExactSequence(value); } if (isSequencePreview(value)) { @@ -28,7 +54,30 @@ function formatRawValue(value: JsonValue): string { return formatMappingPreview(value); } - return typeof value === "object" ? JSON.stringify(value) : String(value); + if (isJsonArray(value)) { + return `[${value.map(formatRawValue).join(PREVIEW_ITEM_SEPARATOR)}]`; + } + + if (typeof value === "object") { + return formatMapping(value); + } + + return String(value); +} + +function isJsonArray(value: JsonValue): value is readonly JsonValue[] { + return Array.isArray(value); +} + +function formatExactSequence(value: ExactSequence): string { + const items = value.items.map(formatRawValue).join(PREVIEW_ITEM_SEPARATOR); + const singleItemComma = value.items.length === 1 ? "," : ""; + + return `(${items}${singleItemComma})`; +} + +function formatExactMapping(value: ExactMapping): string { + return formatMapping(value.entries); } function formatSequencePreview(value: SequencePreview): string { @@ -36,9 +85,19 @@ function formatSequencePreview(value: SequencePreview): string { const tail = value.tail.map(formatRawValue); const items = [...head, "...", ...tail].join(PREVIEW_ITEM_SEPARATOR); - return `${value.typeName}(len=${value.length}, preview=[${items}])`; + const [open, close] = value.typeName === "tuple" ? ["(", ")"] : ["[", "]"]; + + return `${value.typeName}(len=${value.length}, preview=${open}${items}${close})`; } function formatMappingPreview(value: MappingPreview): string { - return `${value.typeName}(len=${value.length}, preview=${JSON.stringify(value.entries)})`; + return `${value.typeName}(len=${value.length}, preview=${formatMapping(value.entries)})`; +} + +function formatMapping(value: Readonly>): string { + const entries = Object.entries(value).map( + ([key, item]) => `${JSON.stringify(key)}: ${formatRawValue(item)}`, + ); + + return `{${entries.join(PREVIEW_ITEM_SEPARATOR)}}`; } diff --git a/src/player.css b/src/player.css index eeb736c..7c54fff 100644 --- a/src/player.css +++ b/src/player.css @@ -1,9 +1,9 @@ .player-controls { display: grid; - grid-template-columns: auto minmax(220px, 1fr) minmax(150px, 180px) 104px; + grid-template-columns: auto minmax(160px, 1fr) minmax(120px, 150px) 104px; align-items: center; - gap: 16px; - padding: 12px 18px; + gap: 12px; + padding: 12px 14px; border-top: 1px solid #c9d0d8; background: #fbfcfd; } diff --git a/src/useTraceSession.test.ts b/src/useTraceSession.test.ts new file mode 100644 index 0000000..010f579 --- /dev/null +++ b/src/useTraceSession.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; + +import { invalidateTraceSession, previousComparableFrame } from "./useTraceSession"; +import type { JsonObject, TraceFrame, TraceRun } from "./types"; + +describe("previousComparableFrame", () => { + it("does not compare a new function scope with caller locals", () => { + const traceRun = run([ + frame(0, "", 0, { callerValue: 1 }), + frame(1, "solve", 1, { argument: 1 }), + ]); + + expect(previousComparableFrame(traceRun, 1)).toBeUndefined(); + }); + + it("resumes comparison with the caller after a nested call returns", () => { + const callerBefore = frame(0, "", 0, { value: 1 }); + const traceRun = run([ + callerBefore, + frame(1, "solve", 1, { argument: 1, result: 2 }), + frame(2, "solve", 1, { argument: 1, result: 2 }), + frame(3, "", 0, { value: 2 }), + ]); + + expect(previousComparableFrame(traceRun, 3)).toBe(callerBefore); + }); + + it("does not inherit locals when the same line calls a function twice", () => { + const traceRun = run([ + { ...frame(0, "solve", 1, { argument: "old" }), event: "return", line: 7 }, + { ...frame(1, "solve", 1, { argument: "new" }), line: 7 }, + ]); + + expect(previousComparableFrame(traceRun, 1)).toBeUndefined(); + }); +}); + +describe("invalidateTraceSession", () => { + it("invalidates an in-flight result and clears displayed trace state", () => { + const activeRunId = { current: 4 }; + const dispatch = vi.fn(); + const stop = vi.fn(); + const resetStep = vi.fn(); + + invalidateTraceSession({ + activeRunId, + dispatch, + playback: { stop, resetStep }, + }); + + expect(activeRunId.current).toBe(5); + expect(stop).toHaveBeenCalledOnce(); + expect(resetStep).toHaveBeenCalledOnce(); + expect(dispatch).toHaveBeenCalledWith({ type: "reset" }); + }); +}); + +function run(frames: readonly TraceFrame[]): TraceRun { + return { sourceLines: [], frames }; +} + +function frame( + step: number, + scopeName: string, + callDepth: number, + locals: JsonObject, +): TraceFrame { + return { + step, + event: "line", + line: step + 1, + lineText: "pass", + scopeName, + callDepth, + locals, + returnValue: null, + }; +} diff --git a/src/useTraceSession.ts b/src/useTraceSession.ts index d1e5ef5..77147a2 100644 --- a/src/useTraceSession.ts +++ b/src/useTraceSession.ts @@ -36,6 +36,10 @@ type StartTraceRunOptions = { readonly playback: PlaybackState; }; +type TraceInvalidationOptions = Pick & { + readonly playback: Pick; +}; + type TraceSessionOptions = { readonly initialCode: string; }; @@ -68,8 +72,8 @@ export function useTraceSession({ initialCode }: TraceSessionOptions) { ...runState, ...playback, currentFrame: currentFrame(runState.traceRun, playback.currentStep), - previousFrame: currentFrame(runState.traceRun, playback.currentStep - 1), - reset: () => resetTraceSession({ activeRunId, dispatch, playback }), + previousFrame: previousComparableFrame(runState.traceRun, playback.currentStep), + reset: () => invalidateTraceSession({ activeRunId, dispatch, playback }), runTrace: (code: string) => startTraceRun({ code, activeRunId, dispatch, playback }), }; } @@ -148,7 +152,7 @@ function startTraceRun(options: StartTraceRunOptions) { .finally(() => stopRunning({ ...options, runId })); } -function resetTraceSession(options: Omit) { +export function invalidateTraceSession(options: TraceInvalidationOptions) { options.activeRunId.current += 1; options.playback.stop(); options.playback.resetStep(); @@ -221,3 +225,32 @@ async function loadTrace(pythonCode: string): Promise { function currentFrame(traceRun: TraceRun | null, step: number): TraceFrame | undefined { return traceRun?.frames[step]; } + +export function previousComparableFrame( + traceRun: TraceRun | null, + step: number, +): TraceFrame | undefined { + const frame = currentFrame(traceRun, step); + + if (!frame) { + return undefined; + } + + for (let index = step - 1; index >= 0; index -= 1) { + const candidate = traceRun?.frames[index]; + + if (!candidate || candidate.callDepth < frame.callDepth) { + return undefined; + } + + if (candidate.callDepth === frame.callDepth && candidate.scopeName === frame.scopeName) { + if (candidate.event === "return") { + return undefined; + } + + return candidate; + } + } + + return undefined; +} diff --git a/src/valuePreview.ts b/src/valuePreview.ts index e314a35..1cece52 100644 --- a/src/valuePreview.ts +++ b/src/valuePreview.ts @@ -18,6 +18,18 @@ export type MappingPreview = JsonObject & { readonly truncated: true; }; +export type ExactSequence = JsonObject & { + readonly __pyweaveExact: "sequence"; + readonly typeName: "tuple"; + readonly items: readonly JsonValue[]; +}; + +export type ExactMapping = JsonObject & { + readonly __pyweaveExact: "mapping"; + readonly typeName: "dict"; + readonly entries: JsonObject; +}; + export type PreviewCell = { readonly known: boolean; readonly value: JsonValue; @@ -26,19 +38,56 @@ export type PreviewCell = { const PREVIEW_MARKER = "__pyweavePreview"; const SEQUENCE_PREVIEW = "sequence"; const MAPPING_PREVIEW = "mapping"; +const EXACT_MARKER = "__pyweaveExact"; +const EXACT_SEQUENCE = "sequence"; +const EXACT_MAPPING = "mapping"; +const EXACT_SEQUENCE_KEYS = [EXACT_MARKER, "typeName", "items"] as const; +const EXACT_MAPPING_KEYS = [EXACT_MARKER, "typeName", "entries"] as const; const UNKNOWN_PREVIEW_VALUE = ""; +export function isExactSequence(value: JsonValue): value is ExactSequence { + const object = jsonObject(value); + + return Boolean( + object && + hasExactKeys(object, EXACT_SEQUENCE_KEYS) && + object[EXACT_MARKER] === EXACT_SEQUENCE && + object.typeName === "tuple" && + Array.isArray(object.items) && + object.items.every(isJsonValue), + ); +} + +export function isExactMapping(value: JsonValue): value is ExactMapping { + const object = jsonObject(value); + const entries = object ? jsonObject(object.entries) : undefined; + + return Boolean( + object && + entries && + hasExactKeys(object, EXACT_MAPPING_KEYS) && + object[EXACT_MARKER] === EXACT_MAPPING && + object.typeName === "dict" && + Object.values(entries).every(isJsonValue), + ); +} + export function isSequencePreview(value: JsonValue): value is SequencePreview { const object = jsonObject(value); return Boolean( object && object[PREVIEW_MARKER] === SEQUENCE_PREVIEW && - typeof object.typeName === "string" && - typeof object.length === "number" && + (object.typeName === "list" || object.typeName === "tuple") && + isNonNegativeInteger(object.length) && Array.isArray(object.head) && - typeof object.tailStart === "number" && - Array.isArray(object.tail), + object.head.every(isJsonValue) && + isNonNegativeInteger(object.tailStart) && + Array.isArray(object.tail) && + object.tail.every(isJsonValue) && + object.truncated === true && + object.head.length <= object.tailStart && + object.tailStart + object.tail.length <= object.length, ); } @@ -48,9 +97,10 @@ export function isMappingPreview(value: JsonValue): value is MappingPreview { return Boolean( object && object[PREVIEW_MARKER] === MAPPING_PREVIEW && - typeof object.typeName === "string" && - typeof object.length === "number" && - jsonObject(object.entries), + object.typeName === "dict" && + isNonNegativeInteger(object.length) && + jsonObject(object.entries) && + object.truncated === true, ); } @@ -68,7 +118,34 @@ export function sequencePreviewCell(preview: SequencePreview, index: number): Pr return { known: false, value: UNKNOWN_PREVIEW_VALUE }; } -function jsonObject(value: JsonValue): JsonObject | undefined { +function hasExactKeys(object: JsonObject, expectedKeys: readonly string[]): boolean { + const keys = Object.keys(object); + + return keys.length === expectedKeys.length && expectedKeys.every((key) => Object.hasOwn(object, key)); +} + +function isNonNegativeInteger(value: JsonValue | undefined): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + +function isJsonValue(value: unknown): value is JsonValue { + if (value === null || typeof value === "string" || typeof value === "boolean") { + return true; + } + + if (typeof value === "number") { + return Number.isFinite(value); + } + + if (Array.isArray(value)) { + return value.every(isJsonValue); + } + + const object = jsonObject(value); + return Boolean(object && Object.values(object).every(isJsonValue)); +} + +function jsonObject(value: unknown): JsonObject | undefined { if (typeof value !== "object" || value === null) { return undefined; } diff --git a/tests/e2e/visualization.spec.ts b/tests/e2e/visualization.spec.ts index a2414af..7dc937a 100644 --- a/tests/e2e/visualization.spec.ts +++ b/tests/e2e/visualization.spec.ts @@ -80,6 +80,40 @@ test("renders the teaching visualization and English locale", async ({ page }) = await expect(page.getByLabel("Next")).toBeVisible(); }); +test("clears the stale trace as soon as source code changes", async ({ page }) => { + await page.goto("/"); + + await expect(page.locator(".array-node")).toHaveCount(5); + await page.locator(".monaco-editor").click(); + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.type("value = 1"); + + await expect(page.locator(".array-node")).toHaveCount(0); + await expect(page.locator(".step-counter")).toContainText("0 / 0"); + await expect(page.locator('.editor-shell[data-current-line=""]')).toBeVisible(); +}); + +test("keeps playback controls visible at the minimum window size", async ({ page }) => { + await page.setViewportSize({ width: 1024, height: 640 }); + await page.goto("/"); + + await expect(page.locator(".step-counter")).toBeVisible(); + const bounds = await page.locator(".player-controls").evaluate((controls) => { + const counter = controls.querySelector(".step-counter"); + const pane = controls.closest(".flow-pane"); + + return { + controlsRight: controls.getBoundingClientRect().right, + counterRight: counter?.getBoundingClientRect().right ?? Number.POSITIVE_INFINITY, + paneRight: pane?.getBoundingClientRect().right ?? 0, + viewportWidth: window.innerWidth, + }; + }); + + expect(bounds.controlsRight).toBeLessThanOrEqual(bounds.paneRight); + expect(bounds.counterRight).toBeLessThanOrEqual(bounds.viewportWidth); +}); + function frame(step: number, line: number, locals: Record) { return { step,