diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2d6d503 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + build-test: + name: build, vet & test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: "1.26.5" + cache: true + + - name: go build + run: go build ./... + + - name: go vet + run: go vet ./... + + - name: go test + run: go test -race ./... + + - name: gofmt + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "these files need gofmt:" >&2 + echo "$unformatted" >&2 + exit 1 + fi + + dashboard: + # Guard against drift between the web/ source and the vendored bundle that + # the Go binary embeds (internal/dashboard/assets/index.html). Rebuild the + # dashboard from source and fail if the committed bundle differs — the build + # is byte-reproducible (deps pinned via yarn.lock), so a mismatch means + # someone edited web/ without re-vendoring. + name: dashboard bundle up to date + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "26" + + - name: Enable corepack (pinned yarn from packageManager) + run: npm install -g corepack@latest && corepack enable + + - name: Install dashboard deps + working-directory: web + run: yarn install --immutable + + - name: Build dashboard + working-directory: web + run: yarn build + + - name: Verify vendored bundle matches source + run: | + cp web/dist/index.html internal/dashboard/assets/index.html + if ! git diff --quiet -- internal/dashboard/assets/index.html; then + echo "::error file=internal/dashboard/assets/index.html::vendored dashboard bundle is stale — rebuild and commit it" + echo "Fix locally with:" >&2 + echo " yarn --cwd web build && cp web/dist/index.html internal/dashboard/assets/index.html" >&2 + git --no-pager diff --stat -- internal/dashboard/assets/index.html + exit 1 + fi + echo "dashboard bundle matches source ✓" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a4ff696 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,49 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +permissions: + contents: write + +jobs: + build-test: + name: build & test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: "1.26.5" + cache: true + + - name: go build + run: go build ./... + + - name: go test + run: go test -race ./... + + goreleaser: + name: goreleaser + needs: build-test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: "1.26.5" + cache: true + + - name: Run goreleaser + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 + with: + version: "~> v2" + args: release --clean --parallelism 1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77dc419 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Build output +/cubit +/dist/ +*.exe + +# Go +*.test +*.out +coverage.txt + +# Web dashboard build (the built asset is vendored at +# internal/dashboard/assets/index.html; the raw dist/ and deps are not tracked) +/web/node_modules/ +/web/dist/ +/web/.yarn/ + +# Editor / OS +.DS_Store diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..c067af3 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,76 @@ +# goreleaser configuration for cubit. +# +# Builds a single binary, `cubit`, from ./cmd/cubit. It runs both as a local +# developer tool (self-updates via `cubit update`) and inside CI via the +# composite action in this repo, so it ships for both linux and darwin. +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - id: cubit + main: ./cmd/cubit + binary: cubit + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X main.version={{ .Version }} + +# One archive so consumers get everything, plus a raw uncompressed binary for +# curl-and-run / CI usage that doesn't want an extraction step. +archives: + - id: cubit + ids: + - cubit + name_template: >- + cubit_{{ .Version }}_{{ .Os }}_{{ .Arch }} + + - id: raw + ids: + - cubit + formats: + - binary + name_template: >- + {{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + +checksum: + name_template: checksums.txt + +snapshot: + version_template: "{{ incpatch .Version }}-snapshot" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + +release: + github: + owner: wardnet + name: cubit + extra_files: + - glob: scripts/install.sh + header: | + ## cubit + + Continuous benchmarking and performance-regression tracking — records + baselines in a git branch and posts non-blocking baseline-vs-PR comparisons. + + **Install / update** + + ```sh + curl -fsSL https://github.com/wardnet/cubit/releases/latest/download/install.sh | sh + ``` + + `cubit update` self-updates the CLI in place. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d68577d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 wardnet + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b4df27a..d976e4d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,54 @@ +cubit + # cubit -Continuous benchmarking and performance-regression tracking — runs benchmarks, records baselines in a git branch, renders trend dashboards, and posts non-blocking baseline-vs-PR comparisons. + +Continuous benchmarking and performance-regression tracking. + +`cubit` runs your benchmarks, records baselines in a git branch, and reports how +a change moves performance — locally for a developer, or as a **non-blocking PR +comment** in CI. It is a *light gate*: the numbers and the trend dashboard inform +a human go/no-go, nothing hard-fails. + +Sibling to [bulwark](https://github.com/wardnet/bulwark) (code-quality scanning), +built for the [wardnet](https://github.com/wardnet) daemon's benchmarks. + +## How it works + +1. Your CI runs the benchmarks (today: Rust [criterion](https://github.com/bheisler/criterion.rs)). +2. `cubit compare` ingests the output, diffs it against the baseline recorded on + the `cubit-state` branch, and posts a Markdown table (baseline vs current, Δ%) + plus a link to a trend dashboard. +3. On the default branch, `cubit record` updates the baseline — keyed by commit + SHA — for the next PR to compare against. + +Baselines and history live in an orphan git branch (no database, no hosted +service). The dashboard renders from that branch as a CI artifact and via a local +`cubit serve`, so it works on private repos and repos whose GitHub Pages slot is +already taken. + +## Commands + +| Command | Purpose | +|---|---| +| `cubit compare` | Ingest a run, diff against a baseline, render the report | +| `cubit record` | Ingest a run and write it as a baseline record (SHA-keyed) | +| `cubit update` | Self-update to the latest release | +| `cubit version` | Print the version | + +## Install + +```sh +curl -fsSL https://github.com/wardnet/cubit/releases/latest/download/install.sh | sh +``` + +`cubit update` self-updates the CLI in place. + +## Status + +Early. The ingest → compare → report spine works; baseline persistence +(`cubit-state` branch), the release/action plumbing, and the React trend +dashboard are in progress. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..1f2f419 --- /dev/null +++ b/action.yml @@ -0,0 +1,170 @@ +name: cubit +description: > + Run benchmarks through cubit — compare a PR against the recorded baseline and + post a non-blocking summary comment, or record a new baseline on the default + branch. Never fails the build; the comment + dashboard inform a human. + +inputs: + version: + description: cubit version to install (omit or "latest" for the latest release) + default: latest + criterion-dir: + description: criterion output directory to ingest (relative to the repo root) + default: target/criterion + bench-command: + description: > + optional shell command that produces the criterion output (e.g. + "cargo bench --bench dns_components"). Omit if an earlier step already ran + the benchmarks. + default: "" + state-branch: + description: orphan branch cubit stores baselines on + default: cubit-state + threshold: + description: flag benchmarks slower than baseline by more than this percent + default: "15" + dashboard-url: + description: trend-dashboard URL to link from the PR comment + default: "" + mode: + description: > + "auto" (default) compares on pull_request and records on a push to the + default branch. Force with "compare" or "record". + default: auto + github-token: + description: token used to post the PR comment and push the state branch + default: ${{ github.token }} + +runs: + using: composite + steps: + - name: Install cubit + shell: bash + env: + VERSION: ${{ inputs.version }} + run: | + # install.sh is fetched from the latest release; CUBIT_VERSION pins the + # binary it installs and install.sh verifies its SHA-256 before use. + if [[ "${VERSION}" != "latest" ]]; then + export CUBIT_VERSION="${VERSION}" + fi + export CUBIT_INSTALL_DIR="${RUNNER_TEMP}/cubit-bin" + curl -fsSL -o "${RUNNER_TEMP}/cubit-install.sh" https://github.com/wardnet/cubit/releases/latest/download/install.sh + sh "${RUNNER_TEMP}/cubit-install.sh" + echo "${CUBIT_INSTALL_DIR}" >> "$GITHUB_PATH" + + - name: Run benchmarks + if: inputs.bench-command != '' + shell: bash + env: + BENCH_COMMAND: ${{ inputs.bench-command }} + run: | + eval "$BENCH_COMMAND" + + - name: Fetch state branch + shell: bash + env: + STATE: ${{ inputs.state-branch }} + run: | + # Best-effort: the branch may not exist yet (first ever run). A present + # remote-tracking ref is what cubit reads baselines from, and what a + # record run bases its append on. + git fetch origin "+refs/heads/${STATE}:refs/remotes/origin/${STATE}" 2>/dev/null || true + + - name: cubit + id: cubit + shell: bash + env: + MODE: ${{ inputs.mode }} + DIR: ${{ inputs.criterion-dir }} + STATE: ${{ inputs.state-branch }} + THRESHOLD: ${{ inputs.threshold }} + DASH: ${{ inputs.dashboard-url }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EVENT: ${{ github.event_name }} + PR_BASE: ${{ github.event.pull_request.base.ref }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + set -euo pipefail + report="${RUNNER_TEMP}/cubit-report.md" + dashboard="${RUNNER_TEMP}/cubit-dashboard.html" + echo "report=${report}" >> "$GITHUB_OUTPUT" + echo "dashboard=${dashboard}" >> "$GITHUB_OUTPUT" + + # Resolve auto mode from the triggering event. + mode="$MODE" + if [[ "$mode" == "auto" ]]; then + if [[ "$EVENT" == "pull_request" ]]; then + mode="compare" + elif [[ "$GITHUB_REF_NAME" == "$DEFAULT_BRANCH" ]]; then + mode="record" + else + echo "cubit: nothing to do (push to non-default branch $GITHUB_REF_NAME)" + echo "did-run=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + fi + echo "did-run=true" >> "$GITHUB_OUTPUT" + echo "mode=${mode}" >> "$GITHUB_OUTPUT" + + dash_arg=() + [[ -n "$DASH" ]] && dash_arg=(--dashboard-url "$DASH") + + if [[ "$mode" == "compare" ]]; then + # latest: yields an empty baseline (all "new") the first time, + # which is fine — cubit does not fail on a missing baseline. + # + # This is a light gate: a compare error (e.g. no benchmark output was + # produced) must NOT fail the consuming workflow. `if ! ...` is exempt + # from `set -e`, so we fall back to a short note and keep the job green. + if ! cubit compare --repo . --criterion-dir "$DIR" \ + --baseline-ref "latest:${PR_BASE}" \ + --commit "$PR_HEAD_SHA" --branch "$PR_HEAD_REF" \ + --threshold "$THRESHOLD" "${dash_arg[@]}" \ + --out "$report"; then + printf '## cubit — performance\n\n⚠️ cubit could not produce a report — no benchmark output was found at `%s` (did the benchmark step run?).\n' "$DIR" > "$report" + fi + # Render the standalone trend dashboard for the run artifact + # (best-effort — a missing state branch just yields an empty page). + cubit serve --repo . --state-branch "$STATE" --branch "$PR_BASE" \ + --out "$dashboard" || true + else + # record: base the local state branch on origin's tip (if any) so the + # append's compare-and-swap lands on top of existing history. + if git rev-parse --verify --quiet "refs/remotes/origin/${STATE}" >/dev/null; then + git branch -f "$STATE" "refs/remotes/origin/${STATE}" + fi + git config user.name "cubit" + git config user.email "cubit@users.noreply.github.com" + # Recording the baseline is best-effort: a failure to record or push + # (e.g. the state branch diverged, or a concurrent update won the CAS) + # must not fail the workflow — it just means the next PR compares + # against a slightly older baseline. Surface it on stderr instead. + if cubit record --persist --repo . --criterion-dir "$DIR" \ + --commit "$GITHUB_SHA" --branch "$DEFAULT_BRANCH" --state-branch "$STATE"; then + git push origin "$STATE" \ + || echo "cubit: push of ${STATE} failed (diverged or concurrent update?) — baseline not updated" >&2 + else + echo "cubit: recording the baseline failed — baseline not updated" >&2 + fi + fi + + - name: Upload report artifact + if: always() && steps.cubit.outputs.mode == 'compare' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: cubit-report-${{ github.job }} + path: | + ${{ steps.cubit.outputs.report }} + ${{ steps.cubit.outputs.dashboard }} + if-no-files-found: ignore + retention-days: 14 + + - name: Post PR comment + if: github.event_name == 'pull_request' && steps.cubit.outputs.mode == 'compare' + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 + with: + GITHUB_TOKEN: ${{ inputs.github-token }} + header: cubit + path: ${{ steps.cubit.outputs.report }} diff --git a/assets/cubit-logo.png b/assets/cubit-logo.png new file mode 100644 index 0000000..c8a7254 Binary files /dev/null and b/assets/cubit-logo.png differ diff --git a/cmd/cubit/compare.go b/cmd/cubit/compare.go new file mode 100644 index 0000000..cfd55d0 --- /dev/null +++ b/cmd/cubit/compare.go @@ -0,0 +1,93 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "wardnet/cubit/internal/gitstore" + "wardnet/cubit/internal/model" + "wardnet/cubit/internal/report" +) + +func newCompareCmd() *cobra.Command { + var ( + criterionDir string + baselinePath string + baselineRef string + repo string + stateBranch string + commit string + branch string + threshold float64 + dashboardURL string + out string + ) + cmd := &cobra.Command{ + Use: "compare", + Short: "Compare a benchmark run against a baseline and render a report", + Long: "Ingests a criterion output directory, diffs it against a baseline, and " + + "writes a Markdown report suitable for a PR comment. The baseline comes " + + "from --baseline (a file) or --baseline-ref (a record on the cubit-state " + + "branch). Advisory only — it never fails the build.", + Args: cobra.NoArgs, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, _ []string) error { + cur, err := buildRun(criterionDir, commit, branch, "") + if err != nil { + return err + } + baseline, err := resolveBaseline(baselinePath, baselineRef, repo, stateBranch) + if err != nil { + return err + } + deltas := model.Compare(cur, baseline, threshold) + md := report.Markdown(deltas, cur, threshold, dashboardURL) + return writeOut(out, []byte(md)) + }, + } + f := cmd.Flags() + f.StringVar(&criterionDir, "criterion-dir", "target/criterion", "criterion output directory to ingest") + f.StringVar(&baselinePath, "baseline", "", "baseline run JSON file (takes precedence over --baseline-ref)") + f.StringVar(&baselineRef, "baseline-ref", "", "baseline from the cubit-state branch: a commit SHA, \"latest\", or \"latest:\"") + f.StringVar(&repo, "repo", ".", "git repository holding the cubit-state branch") + f.StringVar(&stateBranch, "state-branch", gitstore.DefaultBranch, "branch storing recorded runs") + f.StringVar(&commit, "commit", "", "commit SHA to label the current run with") + f.StringVar(&branch, "branch", "", "branch to label the current run with") + f.Float64Var(&threshold, "threshold", 15, "flag benchmarks slower than baseline by more than this percent") + f.StringVar(&dashboardURL, "dashboard-url", "", "trend-dashboard URL to link from the report") + f.StringVar(&out, "out", "-", "write the Markdown report here (\"-\" for stdout)") + return cmd +} + +// resolveBaseline loads the baseline run from an explicit file, or from a +// reference on the cubit-state branch, or returns an empty run (everything +// reported as new) when neither is given or the reference is not found yet. +func resolveBaseline(path, ref, repo, stateBranch string) (model.Run, error) { + if path != "" { + return loadRun(path) + } + if ref == "" { + return model.Run{}, nil + } + st := gitstore.New(repo, stateBranch) + switch { + case ref == "latest": + run, _, err := st.Latest("") + return run, err + case strings.HasPrefix(ref, "latest:"): + run, _, err := st.Latest(strings.TrimPrefix(ref, "latest:")) + return run, err + default: + run, found, err := st.Read(ref) + if err != nil { + return model.Run{}, err + } + if !found { + return model.Run{}, fmt.Errorf("no recorded baseline for %q on %q", ref, stateBranch) + } + return run, nil + } +} diff --git a/cmd/cubit/main.go b/cmd/cubit/main.go new file mode 100644 index 0000000..badc060 --- /dev/null +++ b/cmd/cubit/main.go @@ -0,0 +1,24 @@ +// Command cubit is a continuous-benchmarking CLI: it ingests a benchmark +// runner's output, compares it against a recorded baseline, and reports the +// delta — locally for a developer, or as a non-blocking PR comment in CI. +package main + +import ( + "fmt" + "os" +) + +// version is overridden at release via -ldflags "-X main.version=". +// A "dev" build (built from source) disables self-update. +var version = "dev" + +func main() { + // A best-effort, once-a-day nudge when a newer release exists. Silent in + // CI, non-interactive, and from-source builds; never blocks the command. + maybeNudgeUpdate() + + if err := newRootCmd().Execute(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} diff --git a/cmd/cubit/record.go b/cmd/cubit/record.go new file mode 100644 index 0000000..89ca90f --- /dev/null +++ b/cmd/cubit/record.go @@ -0,0 +1,63 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" + + "wardnet/cubit/internal/gitstore" +) + +func newRecordCmd() *cobra.Command { + var ( + criterionDir string + commit string + branch string + out string + persist bool + repo string + stateBranch string + ) + cmd := &cobra.Command{ + Use: "record", + Short: "Ingest a benchmark run and write it as a baseline record", + Long: "Ingests a criterion output directory into a normalized run record, " + + "keyed by commit SHA. With --persist it commits the record to the " + + "cubit-state branch (via git plumbing, without a checkout) — the CI " + + "default-branch step that updates the baseline the next PR compares against.", + Args: cobra.NoArgs, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, _ []string) error { + run, err := buildRun(criterionDir, commit, branch, "") + if err != nil { + return err + } + if persist { + if err := gitstore.New(repo, stateBranch).Append(run); err != nil { + return err + } + fmt.Fprintf(cmd.ErrOrStderr(), "recorded %d benchmarks for %s on %s\n", + len(run.Measurements), run.Commit, stateBranch) + } + // Emit JSON to an explicit --out, or to stdout only when we are not + // purely persisting (so `record --persist` stays quiet on stdout). + if out != "" { + return writeJSON(out, run) + } + if !persist { + return writeJSON("-", run) + } + return nil + }, + } + f := cmd.Flags() + f.StringVar(&criterionDir, "criterion-dir", "target/criterion", "criterion output directory to ingest") + f.StringVar(&commit, "commit", "", "commit SHA this run was measured at") + f.StringVar(&branch, "branch", "", "branch this run was measured on") + f.StringVar(&out, "out", "", "also write the run record JSON here (\"-\" for stdout)") + f.BoolVar(&persist, "persist", false, "commit the record to the cubit-state branch") + f.StringVar(&repo, "repo", ".", "git repository holding the cubit-state branch") + f.StringVar(&stateBranch, "state-branch", gitstore.DefaultBranch, "branch to store the record on") + return cmd +} diff --git a/cmd/cubit/root.go b/cmd/cubit/root.go new file mode 100644 index 0000000..8438130 --- /dev/null +++ b/cmd/cubit/root.go @@ -0,0 +1,23 @@ +package main + +import "github.com/spf13/cobra" + +func newRootCmd() *cobra.Command { + root := &cobra.Command{ + Use: "cubit", + Short: "Continuous benchmarking and performance-regression tracking", + Long: "cubit runs benchmarks, records baselines, and reports how a change " + + "moves performance — locally for a developer, or as a non-blocking PR " + + "comment in CI.", + SilenceUsage: true, + SilenceErrors: true, + } + root.AddCommand( + newCompareCmd(), + newRecordCmd(), + newServeCmd(), + newUpdateCmd(), + newVersionCmd(), + ) + return root +} diff --git a/cmd/cubit/run.go b/cmd/cubit/run.go new file mode 100644 index 0000000..c4b7be9 --- /dev/null +++ b/cmd/cubit/run.go @@ -0,0 +1,65 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "time" + + "wardnet/cubit/internal/criterion" + "wardnet/cubit/internal/model" +) + +// buildRun ingests a criterion output directory into a normalized [model.Run] +// stamped with the given identity. An empty timestamp is filled with now (UTC, +// RFC3339) so records sort chronologically. +func buildRun(criterionDir, commit, branch, timestamp string) (model.Run, error) { + ms, err := criterion.Ingest(criterionDir) + if err != nil { + return model.Run{}, err + } + if timestamp == "" { + timestamp = time.Now().UTC().Format(time.RFC3339) + } + return model.Run{ + Schema: model.SchemaVersion, + Tool: "criterion", + Commit: commit, + Branch: branch, + Timestamp: timestamp, + Measurements: ms, + }, nil +} + +// loadRun reads a [model.Run] previously written by `cubit record`. +func loadRun(path string) (model.Run, error) { + var r model.Run + data, err := os.ReadFile(path) // #nosec G304 -- path is an explicit user-provided baseline file + if err != nil { + return r, fmt.Errorf("read baseline: %w", err) + } + if err := json.Unmarshal(data, &r); err != nil { + return r, fmt.Errorf("parse baseline %s: %w", path, err) + } + return r, nil +} + +// writeJSON marshals v as indented JSON to path, or to stdout when path is +// empty or "-". +func writeJSON(path string, v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return writeOut(path, data) +} + +// writeOut writes data to path, or to stdout when path is empty or "-". +func writeOut(path string, data []byte) error { + if path == "" || path == "-" { + _, err := os.Stdout.Write(data) + return err + } + return os.WriteFile(path, data, 0o644) // #nosec G306 -- benchmark records/reports are not secrets +} diff --git a/cmd/cubit/serve.go b/cmd/cubit/serve.go new file mode 100644 index 0000000..53c35ea --- /dev/null +++ b/cmd/cubit/serve.go @@ -0,0 +1,52 @@ +package main + +import ( + "time" + + "github.com/spf13/cobra" + + "wardnet/cubit/internal/dashboard" + "wardnet/cubit/internal/gitstore" +) + +func newServeCmd() *cobra.Command { + var ( + repo string + stateBranch string + branch string + addr string + out string + ) + cmd := &cobra.Command{ + Use: "serve", + Short: "Serve the trend dashboard, or render it to a standalone HTML file", + Long: "Builds the per-benchmark trend dashboard from the cubit-state branch. " + + "With --out, writes a standalone HTML file (data inlined) and exits — the " + + "form CI uploads as an artifact. Otherwise serves it on --addr for local viewing.", + Args: cobra.NoArgs, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(_ *cobra.Command, _ []string) error { + store := gitstore.New(repo, stateBranch) + if out != "" { + d, err := dashboard.Build(store, branch, time.Now()) + if err != nil { + return err + } + html, err := dashboard.Render(d) + if err != nil { + return err + } + return writeOut(out, html) + } + return dashboard.Serve(store, branch, addr) + }, + } + f := cmd.Flags() + f.StringVar(&repo, "repo", ".", "git repository holding the cubit-state branch") + f.StringVar(&stateBranch, "state-branch", gitstore.DefaultBranch, "branch storing recorded runs") + f.StringVar(&branch, "branch", "", "only chart runs recorded on this branch (empty = all)") + f.StringVar(&addr, "addr", "127.0.0.1:7777", "address to serve the dashboard on") + f.StringVar(&out, "out", "", "render a standalone HTML file here and exit (\"-\" for stdout)") + return cmd +} diff --git a/cmd/cubit/update.go b/cmd/cubit/update.go new file mode 100644 index 0000000..2118c1d --- /dev/null +++ b/cmd/cubit/update.go @@ -0,0 +1,287 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/spf13/cobra" + "golang.org/x/mod/semver" +) + +// releaseBaseURL is the GitHub project URL releases are downloaded from. +// It is a variable so tests can point it at a local HTTP server. +var releaseBaseURL = "https://github.com/wardnet/cubit" + +// updateCheckTTL bounds how often the background update nudge hits GitHub. +const updateCheckTTL = 24 * time.Hour + +// releaseAssetURL returns the download URL for a named asset of release ver. +func releaseAssetURL(ver, asset string) string { + return fmt.Sprintf("%s/releases/download/v%s/%s", releaseBaseURL, ver, asset) +} + +// canonicalVersion prefixes a bare version ("1.0.1") with the "v" that +// golang.org/x/mod/semver requires and reports whether the result is valid +// semver. Both the release tag (redirect target, "v" trimmed) and the injected +// build version are bare, so both flow through here. +func canonicalVersion(v string) (string, bool) { + sv := "v" + v + return sv, semver.IsValid(sv) +} + +// updateAvailable reports whether latest is a strictly newer release than +// current, so the nudge and `cubit update` only offer a real upgrade — never a +// sideways or downward move. Comparison is semantic, not lexical. An empty or +// unparseable latest OR current (a failed/never-run check, or a non-release +// build) is treated as "no update". +func updateAvailable(latest, current string) bool { + l, lok := canonicalVersion(latest) + c, cok := canonicalVersion(current) + if !lok || !cok { + return false + } + return semver.Compare(l, c) > 0 +} + +func newUpdateCmd() *cobra.Command { + return &cobra.Command{ + Use: "update", + Short: "Update cubit to the latest release", + Args: cobra.NoArgs, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, _ []string) error { + if version == "dev" { + return errors.New("this cubit was built from source — update via git, not 'cubit update'") + } + exe, err := os.Executable() + if err != nil { + return err + } + exe, err = filepath.EvalSymlinks(exe) + if err != nil { + return err + } + latest, err := latestReleaseVersion(cmd.Context(), http.DefaultClient) + if err != nil { + return fmt.Errorf("resolve latest release: %w", err) + } + // A non-release build version (not "dev", but not valid semver either) + // can't be ordered against the latest tag. Surface it explicitly on this + // explicit-action path rather than letting updateAvailable's silent + // false read as "already the latest release". + if _, ok := canonicalVersion(version); !ok { + return fmt.Errorf("running version %q is not a recognized release version — reinstall from a GitHub release to enable updates", version) + } + if !updateAvailable(latest, version) { + fmt.Fprintf(cmd.OutOrStdout(), "cubit v%s is already the latest release\n", version) + return nil + } + fmt.Fprintf(cmd.OutOrStdout(), "updating cubit v%s -> v%s...\n", version, latest) + if err := selfUpdate(cmd.Context(), latest, exe); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "updated %s to v%s\n", exe, latest) + return nil + }, + } +} + +// latestReleaseVersion resolves the latest released version (without the "v" +// prefix) by reading the redirect target of the releases/latest URL, avoiding +// the rate-limited GitHub API. +func latestReleaseVersion(ctx context.Context, client *http.Client) (string, error) { + noRedirect := *client + noRedirect.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, releaseBaseURL+"/releases/latest", nil) + if err != nil { + return "", err + } + resp, err := noRedirect.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 300 || resp.StatusCode >= 400 { + return "", fmt.Errorf("expected a redirect from %s, got HTTP %d", req.URL, resp.StatusCode) + } + + // Location ends in .../releases/tag/v. + loc := resp.Header.Get("Location") + tag := loc[strings.LastIndex(loc, "/")+1:] + if !strings.HasPrefix(tag, "v") { + return "", fmt.Errorf("cannot parse release tag from redirect %q", loc) + } + return strings.TrimPrefix(tag, "v"), nil +} + +// selfUpdate downloads the raw cubit binary for the given version, verifies its +// SHA-256 against the release's checksums.txt, and atomically replaces the +// binary at exe. +func selfUpdate(ctx context.Context, ver, exe string) error { + asset := fmt.Sprintf("cubit_%s_%s_%s", ver, runtime.GOOS, runtime.GOARCH) + url := releaseAssetURL(ver, asset) + + // Stage in the same directory so the final rename is atomic. + tmp, err := os.CreateTemp(filepath.Dir(exe), ".cubit-update-*") + if err != nil { + return fmt.Errorf("stage update next to %s (is the directory writable?): %w", exe, err) + } + tmpPath := tmp.Name() + if err := tmp.Close(); err != nil { + return err + } + defer func() { _ = os.Remove(tmpPath) }() + + if err := downloadBinary(ctx, url, tmpPath); err != nil { + return err + } + if err := verifyChecksum(ctx, http.DefaultClient, ver, asset, tmpPath); err != nil { + return err + } + if err := os.Chmod(tmpPath, 0o755); err != nil { // #nosec G302 -- the replacement binary must be executable + return err + } + if err := os.Rename(tmpPath, exe); err != nil { + return fmt.Errorf("replace %s (is it writable?): %w", exe, err) + } + return nil +} + +// downloadBinary fetches url and writes it to dst. +func downloadBinary(ctx context.Context, url, dst string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d fetching %s — asset not found for %s/%s", + resp.StatusCode, url, runtime.GOOS, runtime.GOARCH) + } + + f, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) // #nosec G304 -- dst is our own staged temp path, not user input + if err != nil { + return err + } + _, copyErr := io.Copy(f, resp.Body) + closeErr := f.Close() + if copyErr != nil { + return copyErr + } + return closeErr +} + +// verifyChecksum fetches the release's checksums.txt and compares the named +// asset's SHA-256 against the file at path. +func verifyChecksum(ctx context.Context, client *http.Client, ver, asset, path string) error { + url := releaseAssetURL(ver, "checksums.txt") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, url) + } + sums, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var want string + for _, line := range strings.Split(string(sums), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[1] == asset { + want = fields[0] + break + } + } + if want == "" { + return fmt.Errorf("no checksum for %s in %s", asset, url) + } + + f, err := os.Open(path) // #nosec G304 -- path is our own staged temp path, not user input + if err != nil { + return err + } + defer func() { _ = f.Close() }() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + if got := hex.EncodeToString(h.Sum(nil)); got != want { + return fmt.Errorf("checksum mismatch for %s: got %s, want %s", asset, got, want) + } + return nil +} + +// updateCheckState is the on-disk cache for the background update nudge. +type updateCheckState struct { + CheckedAt time.Time `json:"checked_at"` + Latest string `json:"latest"` +} + +// maybeNudgeUpdate prints a one-line notice on stderr when a newer release is +// known. It contacts GitHub at most once per updateCheckTTL, never in CI, +// non-interactive, or from-source ("dev") runs, and stays silent on any failure. +func maybeNudgeUpdate() { + if version == "dev" || os.Getenv("CI") != "" { + return + } + if fi, err := os.Stderr.Stat(); err != nil || fi.Mode()&os.ModeCharDevice == 0 { + return + } + cacheDir, err := os.UserCacheDir() + if err != nil { + return + } + path := filepath.Join(cacheDir, "cubit", "update-check.json") + + var st updateCheckState + if data, err := os.ReadFile(path); err == nil { // #nosec G304 -- path is built from os.UserCacheDir(), not user input + _ = json.Unmarshal(data, &st) + } + if time.Since(st.CheckedAt) >= updateCheckTTL { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + latest, err := latestReleaseVersion(ctx, http.DefaultClient) + // Record the attempt even when the check fails (offline, blocked) — + // otherwise every invocation past the TTL re-pays the network timeout. + st.CheckedAt = time.Now() + if err == nil { + st.Latest = latest + } + if data, err := json.Marshal(st); err == nil { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err == nil { + _ = os.WriteFile(path, data, 0o600) + } + } + } + if updateAvailable(st.Latest, version) { + _, _ = fmt.Fprintf(os.Stderr, "\ncubit v%s is available (you have v%s) — run 'cubit update'\n", + st.Latest, version) + } +} diff --git a/cmd/cubit/version.go b/cmd/cubit/version.go new file mode 100644 index 0000000..988f9d4 --- /dev/null +++ b/cmd/cubit/version.go @@ -0,0 +1,19 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the cubit version", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + fmt.Fprintf(cmd.OutOrStdout(), "cubit %s\n", version) + return nil + }, + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4e48196 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module wardnet/cubit + +go 1.26.4 + +toolchain go1.26.5 + +require ( + github.com/spf13/cobra v1.10.2 + golang.org/x/mod v0.38.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dcd844c --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/criterion/ingest.go b/internal/criterion/ingest.go new file mode 100644 index 0000000..f8f89c7 --- /dev/null +++ b/internal/criterion/ingest.go @@ -0,0 +1,158 @@ +// Package criterion ingests a criterion-rs output tree into Cubit's normalized +// [model.Measurement]s. +// +// criterion writes one directory per benchmark under target/criterion, each +// containing a new/estimates.json (the timing statistics of the most recent +// run) and a benchmark.json (the benchmark's identity + declared throughput). +// We read estimates.json for the numbers and benchmark.json for a stable ID, +// falling back to the directory path when the metadata file is absent. We take +// the *median* rather than the mean: it is the statistic least perturbed by the +// occasional slow sample on a noisy CI runner. +package criterion + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "wardnet/cubit/internal/model" +) + +// estimates mirrors the subset of criterion's new/estimates.json we consume. +// All values are in nanoseconds. +type estimates struct { + Median struct { + PointEstimate float64 `json:"point_estimate"` + ConfidenceInterval struct { + LowerBound float64 `json:"lower_bound"` + UpperBound float64 `json:"upper_bound"` + } `json:"confidence_interval"` + } `json:"median"` +} + +// benchmarkMeta mirrors the subset of criterion's benchmark.json we consume. +type benchmarkMeta struct { + GroupID string `json:"group_id"` + FunctionID string `json:"function_id"` + ValueStr string `json:"value_str"` + Throughput []struct { + PerIteration uint64 `json:"per_iteration"` + } `json:"throughput"` +} + +// id builds a stable, human-readable benchmark id from the criterion metadata: +// "group/function/value", skipping the segments criterion left empty. +func (m benchmarkMeta) id() string { + parts := make([]string, 0, 3) + for _, p := range []string{m.GroupID, m.FunctionID, m.ValueStr} { + if p != "" { + parts = append(parts, p) + } + } + return strings.Join(parts, "/") +} + +func (m benchmarkMeta) throughput() float64 { + if len(m.Throughput) == 0 || m.Throughput[0].PerIteration == 0 { + return 0 + } + return float64(m.Throughput[0].PerIteration) +} + +// Ingest walks criterionDir (typically /criterion), returning one +// [model.Measurement] per benchmark, sorted by ID. It considers only the +// freshly-measured run — the ...//new/estimates.json files — ignoring +// criterion's retained "base" snapshots. +func Ingest(criterionDir string) ([]model.Measurement, error) { + info, err := os.Stat(criterionDir) + if err != nil { + return nil, fmt.Errorf("criterion directory: %w", err) + } + if !info.IsDir() { + return nil, fmt.Errorf("criterion directory %q is not a directory", criterionDir) + } + + var out []model.Measurement + walkErr := filepath.WalkDir(criterionDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || d.Name() != "estimates.json" { + return nil + } + // Only the current run: ...//new/estimates.json. + if filepath.Base(filepath.Dir(path)) != "new" { + return nil + } + benchDir := filepath.Dir(filepath.Dir(path)) + + est, err := readEstimates(path) + if err != nil { + return fmt.Errorf("%s: %w", path, err) + } + + id := deriveID(criterionDir, benchDir) + var thr float64 + if meta, err := readMeta(filepath.Join(benchDir, "benchmark.json")); err == nil { + if mid := meta.id(); mid != "" { + id = mid + } + thr = meta.throughput() + } + + out = append(out, model.Measurement{ + ID: id, + Unit: "ns", + Value: est.Median.PointEstimate, + Lower: est.Median.ConfidenceInterval.LowerBound, + Upper: est.Median.ConfidenceInterval.UpperBound, + Throughput: thr, + }) + return nil + }) + if walkErr != nil { + return nil, walkErr + } + if len(out) == 0 { + return nil, fmt.Errorf("no benchmarks found under %q (expected */new/estimates.json)", criterionDir) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func readEstimates(path string) (estimates, error) { + var e estimates + data, err := os.ReadFile(path) // #nosec G304 -- path comes from our own WalkDir over criterionDir + if err != nil { + return e, err + } + if err := json.Unmarshal(data, &e); err != nil { + return e, err + } + return e, nil +} + +func readMeta(path string) (benchmarkMeta, error) { + var m benchmarkMeta + data, err := os.ReadFile(path) // #nosec G304 -- path is benchmark.json beside an ingested estimates.json + if err != nil { + return m, err + } + if err := json.Unmarshal(data, &m); err != nil { + return m, err + } + return m, nil +} + +// deriveID falls back to the benchmark's path relative to the criterion root +// when benchmark.json is missing, so ingestion still yields a usable ID. +func deriveID(root, benchDir string) string { + rel, err := filepath.Rel(root, benchDir) + if err != nil { + return filepath.Base(benchDir) + } + return filepath.ToSlash(rel) +} diff --git a/internal/dashboard/assets/index.html b/internal/dashboard/assets/index.html new file mode 100644 index 0000000..d50b178 --- /dev/null +++ b/internal/dashboard/assets/index.html @@ -0,0 +1,61 @@ + + + + + + cubit — performance + + + + +
+ + diff --git a/internal/dashboard/dashboard.go b/internal/dashboard/dashboard.go new file mode 100644 index 0000000..8eb0f7e --- /dev/null +++ b/internal/dashboard/dashboard.go @@ -0,0 +1,136 @@ +// Package dashboard turns recorded runs into the trend dashboard: a +// self-contained HTML page (JS + CSS + data inlined) that `cubit serve` hosts +// locally and CI uploads as an artifact. +// +// The React/Vite bundle is built from ../../web and embedded here as a single +// index.html; Build assembles the per-benchmark series from the cubit-state +// branch and Render injects it as a window global the page reads on load. +package dashboard + +import ( + "bytes" + _ "embed" + "encoding/json" + "fmt" + "net" + "net/http" + "sort" + "strings" + "time" + + "wardnet/cubit/internal/gitstore" +) + +//go:embed assets/index.html +var indexHTML []byte + +type point struct { + Commit string `json:"commit"` + ShortCommit string `json:"shortCommit"` + Timestamp string `json:"timestamp"` + Value float64 `json:"value"` + Lower float64 `json:"lower"` + Upper float64 `json:"upper"` +} + +type benchSeries struct { + ID string `json:"id"` + Unit string `json:"unit"` + Points []point `json:"points"` +} + +// Data is the payload the page renders. +type Data struct { + GeneratedAt string `json:"generatedAt"` + Branch string `json:"branch"` + Benchmarks []benchSeries `json:"benchmarks"` +} + +// Build assembles the dashboard payload from the store's recorded runs, +// optionally filtered to one branch, with each benchmark's points ordered +// oldest-first (the manifest order). +func Build(store *gitstore.Store, branch string, now time.Time) (Data, error) { + runs, err := store.History(branch) + if err != nil { + return Data{}, err + } + byID := map[string]*benchSeries{} + var ids []string + for _, run := range runs { + for _, ms := range run.Measurements { + s, exists := byID[ms.ID] + if !exists { + s = &benchSeries{ID: ms.ID, Unit: ms.Unit} + byID[ms.ID] = s + ids = append(ids, ms.ID) + } + s.Points = append(s.Points, point{ + Commit: run.Commit, + ShortCommit: short(run.Commit), + Timestamp: run.Timestamp, + Value: ms.Value, + Lower: ms.Lower, + Upper: ms.Upper, + }) + } + } + sort.Strings(ids) + d := Data{GeneratedAt: now.UTC().Format(time.RFC3339), Branch: branch} + for _, id := range ids { + d.Benchmarks = append(d.Benchmarks, *byID[id]) + } + return d, nil +} + +// Render returns a standalone HTML page with data injected as a window global +// just before , so it is defined before the page's deferred module +// script runs. +func Render(d Data) ([]byte, error) { + raw, err := json.Marshal(d) + if err != nil { + return nil, err + } + // Guard against a "" sequence inside the JSON prematurely closing + // the injected tag. + safe := strings.ReplaceAll(string(raw), "window.__CUBIT_DATA__=" + safe + ";") + return bytes.Replace(indexHTML, []byte(""), inject, 1), nil +} + +// Serve builds the dashboard on every request (so it reflects the current +// branch state) and serves it at addr until the process is stopped. +func Serve(store *gitstore.Store, branch, addr string) error { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + d, err := Build(store, branch, time.Now()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + html, err := Render(d) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(html) + }) + + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } + fmt.Printf("cubit dashboard: http://%s (ctrl-c to stop)\n", ln.Addr()) + return http.Serve(ln, mux) // #nosec G114 -- local dev dashboard, no untrusted network exposure intended +} + +func short(sha string) string { + if len(sha) > 8 { + return sha[:8] + } + return sha +} diff --git a/internal/gitstore/gitstore.go b/internal/gitstore/gitstore.go new file mode 100644 index 0000000..247c863 --- /dev/null +++ b/internal/gitstore/gitstore.go @@ -0,0 +1,394 @@ +// Package gitstore persists benchmark runs to an orphan git branch (default +// "cubit-state") and reads them back, without ever checking that branch out. +// +// Records live at runs/.json, with an index.json manifest listing +// every recorded run for the dashboard and for resolving "the latest baseline". +// Only default-branch runs are persisted (main-only history), so the branch is +// a small, append-mostly ledger — a good fit for a git branch rather than a +// database. +// +// Writes use git plumbing (hash-object → update-index against a temp index → +// write-tree → commit-tree → update-ref with compare-and-swap), so they never +// touch the caller's working tree and are safe to run mid-build in CI. +package gitstore + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path" + "path/filepath" + "sort" + "strings" + + "wardnet/cubit/internal/model" +) + +// DefaultBranch is the orphan branch runs are stored on. +const DefaultBranch = "cubit-state" + +const ( + runsDir = "runs" + indexPath = "index.json" +) + +// Store reads and writes runs on an orphan branch within a git repository. +type Store struct { + RepoDir string // path to the git repository (worktree or bare) + Branch string // orphan branch name; empty means DefaultBranch +} + +// New returns a Store for repoDir. An empty branch defaults to DefaultBranch. +func New(repoDir, branch string) *Store { + if branch == "" { + branch = DefaultBranch + } + return &Store{RepoDir: repoDir, Branch: branch} +} + +// RunMeta is one entry in the index manifest. +type RunMeta struct { + Commit string `json:"commit"` + Branch string `json:"branch"` + Timestamp string `json:"timestamp"` +} + +// Index returns the manifest of recorded runs, sorted oldest-first by +// timestamp. It returns an empty slice (no error) when the branch or manifest +// does not exist yet. +func (s *Store) Index() ([]RunMeta, error) { + ref, ok, err := s.resolveRef() + if err != nil || !ok { + return nil, err + } + data, ok, err := s.showFile(ref, indexPath) + if err != nil || !ok { + return nil, err + } + var metas []RunMeta + if err := json.Unmarshal(data, &metas); err != nil { + return nil, fmt.Errorf("parse %s: %w", indexPath, err) + } + sort.SliceStable(metas, func(i, j int) bool { return metas[i].Timestamp < metas[j].Timestamp }) + return metas, nil +} + +// Read returns the run recorded for commit sha. The bool is false (with no +// error) when the branch or that record does not exist. +func (s *Store) Read(sha string) (model.Run, bool, error) { + ref, ok, err := s.resolveRef() + if err != nil || !ok { + return model.Run{}, false, err + } + data, ok, err := s.showFile(ref, runPath(sha)) + if err != nil || !ok { + return model.Run{}, false, err + } + var run model.Run + if err := json.Unmarshal(data, &run); err != nil { + return model.Run{}, false, fmt.Errorf("parse %s: %w", runPath(sha), err) + } + return run, true, nil +} + +// Latest returns the most recently recorded run (by index timestamp), matching +// only the given branch when branchFilter is non-empty. The bool is false (no +// error) when nothing has been recorded yet. +func (s *Store) Latest(branchFilter string) (model.Run, bool, error) { + metas, err := s.Index() + if err != nil { + return model.Run{}, false, err + } + for i := len(metas) - 1; i >= 0; i-- { + if branchFilter != "" && metas[i].Branch != branchFilter { + continue + } + run, ok, err := s.Read(metas[i].Commit) + if err != nil { + return model.Run{}, false, err + } + if ok { + return run, true, nil + } + // The manifest lists this commit but its run blob is gone — fall back to + // the next-older matching run rather than reporting "no baseline". + } + return model.Run{}, false, nil +} + +// Append records run at runs/.json and updates index.json, committing +// to the orphan branch via plumbing. It refuses a run with no commit SHA (the +// records are SHA-keyed). Concurrent writers are guarded by a compare-and-swap +// on the branch ref: a racing update makes this fail rather than clobber. +func (s *Store) Append(run model.Run) error { + if strings.TrimSpace(run.Commit) == "" { + return fmt.Errorf("run has no commit SHA — records are keyed by commit") + } + + // The commit the new record builds on: the local branch if present, else the + // remote-tracking ref (the CI fetched-not-checked-out case). Seeding the + // index AND reading the manifest from this SAME base keeps the persisted tree + // and the manifest consistent, and setting the commit parent to it makes the + // new local branch a fast-forward descendant of origin — so the later push is + // not rejected. (Reading the manifest via a separately-resolved ref was the + // bug: with only origin present, the tree was seeded empty while the manifest + // still listed — and thus orphaned — every historical run.) + base, hasBase, err := s.baseCommit() + if err != nil { + return err + } + + // Stage into a throwaway index seeded from the base tree, so existing records + // survive the commit. The index path must NOT pre-exist as an empty file — + // git rejects a zero-byte index — so we hand git a fresh path inside a temp + // dir and let it create the index itself. + tmpDir, err := os.MkdirTemp("", "cubit-index-") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(tmpDir) }() + env := append(os.Environ(), "GIT_INDEX_FILE="+filepath.Join(tmpDir, "index")) + + if hasBase { + if _, err := s.gitEnv(env, nil, "read-tree", base+"^{tree}"); err != nil { + return err + } + } + + // Merge the new run into the manifest read from the exact base we seeded the + // tree from — never a separately-resolved ref — so history and tree agree. + var metas []RunMeta + if hasBase { + if data, ok, err := s.showFile(base, indexPath); err != nil { + return err + } else if ok { + if err := json.Unmarshal(data, &metas); err != nil { + return fmt.Errorf("parse %s at %s: %w", indexPath, short(base), err) + } + } + } + metas = upsertMeta(metas, RunMeta{Commit: run.Commit, Branch: run.Branch, Timestamp: run.Timestamp}) + + runJSON, err := marshal(run) + if err != nil { + return err + } + indexJSON, err := marshal(metas) + if err != nil { + return err + } + + if err := s.stage(env, runPath(run.Commit), runJSON); err != nil { + return err + } + if err := s.stage(env, indexPath, indexJSON); err != nil { + return err + } + + treeOut, err := s.gitEnv(env, nil, "write-tree") + if err != nil { + return err + } + tree := strings.TrimSpace(treeOut) + + msg := fmt.Sprintf("record %s (%s)", short(run.Commit), run.Branch) + commitArgs := []string{"commit-tree", tree, "-m", msg} + if hasBase { + commitArgs = append(commitArgs, "-p", base) + } + commitOut, err := s.gitEnv(env, nil, commitArgs...) + if err != nil { + return err + } + commit := strings.TrimSpace(commitOut) + + // Compare-and-swap the LOCAL branch. The old value is the local ref's current + // commit, or the empty string ("must not exist yet") when we are creating it + // — never the remote-tracking ref, which cannot be CAS'd. When base came from + // origin, the new commit's parent is origin's tip, so this create yields a + // clean fast-forward descendant. + localTip, hasLocal, err := s.tip() + if err != nil { + return err + } + ref := "refs/heads/" + s.Branch + if hasLocal { + _, err = s.git(nil, "update-ref", ref, commit, localTip) + } else { + _, err = s.git(nil, "update-ref", ref, commit, "") + } + return err +} + +// stage hashes content as a blob and adds it to the temp index at repoPath. +func (s *Store) stage(env []string, repoPath string, content []byte) error { + blobOut, err := s.gitEnv(env, content, "hash-object", "-w", "--stdin") + if err != nil { + return err + } + blob := strings.TrimSpace(blobOut) + _, err = s.gitEnv(env, nil, "update-index", "--add", "--cacheinfo", fmt.Sprintf("100644,%s,%s", blob, repoPath)) + return err +} + +// resolveRef returns a readable ref for the branch, preferring the local branch +// and falling back to origin/ (the CI case, where the branch is +// fetched but not checked out). ok is false when neither exists. +func (s *Store) resolveRef() (string, bool, error) { + for _, ref := range []string{"refs/heads/" + s.Branch, "refs/remotes/origin/" + s.Branch} { + out, err := s.git(nil, "rev-parse", "--verify", "--quiet", ref+"^{commit}") + if err == nil && strings.TrimSpace(out) != "" { + return ref, true, nil + } + } + return "", false, nil +} + +// tip returns the local branch's commit for compare-and-swap on write. It only +// considers the local branch (a CAS against a remote-tracking ref would be +// meaningless), so first-write-in-CI creates the branch locally then pushes. +func (s *Store) tip() (string, bool, error) { + out, err := s.git(nil, "rev-parse", "--verify", "--quiet", "refs/heads/"+s.Branch+"^{commit}") + if err != nil { + // rev-parse --quiet exits non-zero when the ref is absent; treat as "no tip". + return "", false, nil + } + sha := strings.TrimSpace(out) + if sha == "" { + return "", false, nil + } + return sha, true, nil +} + +// baseCommit resolves the commit a new record should build on: the local branch +// if it exists, else origin/ (the CI fetched-not-checked-out case). ok +// is false when neither exists (the branch is being created from scratch). +func (s *Store) baseCommit() (string, bool, error) { + for _, ref := range []string{"refs/heads/" + s.Branch, "refs/remotes/origin/" + s.Branch} { + out, err := s.git(nil, "rev-parse", "--verify", "--quiet", ref+"^{commit}") + if err == nil { + if sha := strings.TrimSpace(out); sha != "" { + return sha, true, nil + } + } + } + return "", false, nil +} + +// History returns every recorded run matching branchFilter (empty = all), +// oldest-first, resolving the state ref ONCE. Entries whose run blob is missing +// are skipped. It replaces one Read-per-entry (each of which re-resolved the +// ref) with a single resolution plus one git show per run. +func (s *Store) History(branchFilter string) ([]model.Run, error) { + ref, ok, err := s.resolveRef() + if err != nil || !ok { + return nil, err + } + data, ok, err := s.showFile(ref, indexPath) + if err != nil || !ok { + return nil, err + } + var metas []RunMeta + if err := json.Unmarshal(data, &metas); err != nil { + return nil, fmt.Errorf("parse %s: %w", indexPath, err) + } + sort.SliceStable(metas, func(i, j int) bool { return metas[i].Timestamp < metas[j].Timestamp }) + + var runs []model.Run + for _, m := range metas { + if branchFilter != "" && m.Branch != branchFilter { + continue + } + blob, ok, err := s.showFile(ref, runPath(m.Commit)) + if err != nil { + return nil, err + } + if !ok { + continue + } + var run model.Run + if err := json.Unmarshal(blob, &run); err != nil { + return nil, fmt.Errorf("parse %s: %w", runPath(m.Commit), err) + } + runs = append(runs, run) + } + return runs, nil +} + +// showFile returns the bytes of repoPath at ref. ok is false (no error) when +// the path does not exist at that ref. +func (s *Store) showFile(ref, repoPath string) ([]byte, bool, error) { + cmd := exec.Command("git", "-C", s.RepoDir, "show", ref+":"+repoPath) + // Force a stable C locale: the missing-path detection below matches git's + // English error text, which localized output (LANG/LC_ALL) would otherwise + // break — turning an expected "not found" into a hard error. + cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + // A missing path is expected (empty branch / first run) — not an error. + if strings.Contains(stderr.String(), "does not exist") || + strings.Contains(stderr.String(), "exists on disk, but not in") || + strings.Contains(stderr.String(), "fatal: path") { + return nil, false, nil + } + return nil, false, fmt.Errorf("git show %s:%s: %s", ref, repoPath, strings.TrimSpace(stderr.String())) + } + return stdout.Bytes(), true, nil +} + +func (s *Store) git(stdin []byte, args ...string) (string, error) { + return s.gitEnv(nil, stdin, args...) +} + +func (s *Store) gitEnv(env []string, stdin []byte, args ...string) (string, error) { + cmd := exec.Command("git", append([]string{"-C", s.RepoDir}, args...)...) + base := env + if base == nil { + base = os.Environ() + } + // Force a stable C locale (see showFile) on every git invocation, including + // those carrying a GIT_INDEX_FILE. Later duplicate keys win, so this overrides + // any inherited LANG/LC_ALL. + cmd.Env = append(base, "LC_ALL=C", "LANG=C") + if stdin != nil { + cmd.Stdin = bytes.NewReader(stdin) + } + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), strings.TrimSpace(stderr.String())) + } + return stdout.String(), nil +} + +func upsertMeta(metas []RunMeta, m RunMeta) []RunMeta { + for i := range metas { + if metas[i].Commit == m.Commit { + metas[i] = m + return metas + } + } + return append(metas, m) +} + +func marshal(v any) ([]byte, error) { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return nil, err + } + return append(data, '\n'), nil +} + +func runPath(sha string) string { return path.Join(runsDir, sha+".json") } + +func short(sha string) string { + if len(sha) > 8 { + return sha[:8] + } + return sha +} diff --git a/internal/gitstore/gitstore_test.go b/internal/gitstore/gitstore_test.go new file mode 100644 index 0000000..0f9c972 --- /dev/null +++ b/internal/gitstore/gitstore_test.go @@ -0,0 +1,175 @@ +package gitstore + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "wardnet/cubit/internal/model" +) + +func TestAppendReadLatest(t *testing.T) { + repo := initRepo(t) + s := New(repo, "") + + // Empty store: no index, no latest, no record. + if metas, err := s.Index(); err != nil || len(metas) != 0 { + t.Fatalf("empty Index() = %v, %v; want empty, nil", metas, err) + } + if _, ok, err := s.Latest(""); err != nil || ok { + t.Fatalf("empty Latest() ok=%v err=%v; want false, nil", ok, err) + } + + run1 := model.Run{ + Schema: 1, Tool: "criterion", Commit: "aaaaaaaaaaaaaaaa", Branch: "main", + Timestamp: "2026-07-18T00:00:00Z", + Measurements: []model.Measurement{ + {ID: "dns_cache/get", Unit: "ns", Value: 45.2}, + }, + } + if err := s.Append(run1); err != nil { + t.Fatalf("Append(run1): %v", err) + } + + got, ok, err := s.Read("aaaaaaaaaaaaaaaa") + if err != nil || !ok { + t.Fatalf("Read(run1) ok=%v err=%v", ok, err) + } + if len(got.Measurements) != 1 || got.Measurements[0].Value != 45.2 { + t.Fatalf("Read(run1) = %+v; want the recorded measurement", got.Measurements) + } + + // A newer run on main supersedes as "latest"; branch filter is honored. + run2 := run1 + run2.Commit = "bbbbbbbbbbbbbbbb" + run2.Timestamp = "2026-07-19T00:00:00Z" + run2.Measurements = []model.Measurement{{ID: "dns_cache/get", Unit: "ns", Value: 44.0}} + if err := s.Append(run2); err != nil { + t.Fatalf("Append(run2): %v", err) + } + + metas, err := s.Index() + if err != nil || len(metas) != 2 { + t.Fatalf("Index() = %v (%d), %v; want 2 entries", metas, len(metas), err) + } + if metas[0].Commit != run1.Commit || metas[1].Commit != run2.Commit { + t.Fatalf("Index() not sorted oldest-first: %+v", metas) + } + + latest, ok, err := s.Latest("main") + if err != nil || !ok || latest.Commit != run2.Commit { + t.Fatalf("Latest(main) = %+v ok=%v err=%v; want run2", latest, ok, err) + } + if _, ok, _ := s.Latest("nonexistent-branch"); ok { + t.Fatalf("Latest(nonexistent-branch) should be not-found") + } + + // The orphan-branch writes must never touch the working tree. + if out := gitOut(t, repo, "status", "--porcelain"); out != "" { + t.Fatalf("working tree dirtied by Append: %q", out) + } + if _, err := os.Stat(filepath.Join(repo, "runs")); !os.IsNotExist(err) { + t.Fatalf("runs/ leaked into the working tree") + } +} + +func TestAppendRejectsEmptyCommit(t *testing.T) { + s := New(initRepo(t), "") + if err := s.Append(model.Run{Branch: "main"}); err == nil { + t.Fatal("Append with empty commit should fail") + } +} + +// When only origin/ exists (the CI fetched-not-checked-out shape), +// Append must build on origin's tip: preserve historical run blobs and create a +// local branch that descends from origin (a fast-forward), not a divergent +// parentless orphan that drops history and fails to push. +func TestAppendBuildsOnOriginTrackingRef(t *testing.T) { + repo := initRepo(t) + s := New(repo, "") + + run1 := model.Run{ + Schema: 1, Tool: "criterion", Commit: "aaaaaaaaaaaaaaaa", Branch: "main", + Timestamp: "2026-07-18T00:00:00Z", + Measurements: []model.Measurement{{ID: "dns_cache/get", Unit: "ns", Value: 45.2}}, + } + if err := s.Append(run1); err != nil { + t.Fatalf("Append(run1): %v", err) + } + + // Relocate the branch to a remote-tracking ref only: origin has it, local + // doesn't. + originTip := strings.TrimSpace(gitOut(t, repo, "rev-parse", "refs/heads/cubit-state")) + mustGit(t, repo, "update-ref", "refs/remotes/origin/cubit-state", originTip) + mustGit(t, repo, "update-ref", "-d", "refs/heads/cubit-state") + + run2 := run1 + run2.Commit = "bbbbbbbbbbbbbbbb" + run2.Timestamp = "2026-07-19T00:00:00Z" + if err := s.Append(run2); err != nil { + t.Fatalf("Append(run2) on origin base: %v", err) + } + + // The historical run must survive (this is the regression: seed from origin's + // tree, not an empty index). + if _, ok, err := s.Read("aaaaaaaaaaaaaaaa"); err != nil || !ok { + t.Fatalf("historical run dropped after origin-based append: ok=%v err=%v", ok, err) + } + if metas, err := s.Index(); err != nil || len(metas) != 2 { + t.Fatalf("Index() = %d entries, %v; want 2", len(metas), err) + } + // The new local branch must descend from origin's tip, not be an orphan. + parent := strings.TrimSpace(gitOut(t, repo, "rev-parse", "refs/heads/cubit-state^")) + if parent != originTip { + t.Fatalf("new commit parent = %s; want origin tip %s (divergent orphan)", parent, originTip) + } +} + +// initRepo creates a git repo with one commit on the default branch, so the +// orphan cubit-state branch has a repository to live in. +func initRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + run := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + run("init", "-q", "-b", "main") + // Local identity so cubit's commit-tree works even on a runner with no + // global git config. + run("config", "user.email", "t@t") + run("config", "user.name", "t") + if err := os.WriteFile(filepath.Join(dir, "README"), []byte("x\n"), 0o644); err != nil { + t.Fatal(err) + } + run("add", "-A") + run("commit", "-q", "-m", "init") + return dir +} + +func mustGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +func gitOut(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + out, err := cmd.Output() + if err != nil { + t.Fatalf("git %v: %v", args, err) + } + return string(out) +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..418c30d --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,84 @@ +// Package model defines Cubit's normalized, tool-agnostic benchmark schema. +// +// Every benchmark runner (criterion today, others later) is ingested into a +// [Run]: a set of [Measurement]s captured at one commit. Runs are what get +// persisted to the cubit-state branch and what the comparison + report layers +// operate on, so nothing downstream ever needs to know which runner produced +// the numbers. +package model + +import "sort" + +// SchemaVersion is bumped whenever the on-disk [Run] shape changes +// incompatibly, so the ingest/read layers can refuse or migrate old records. +const SchemaVersion = 1 + +// Measurement is a single benchmark's summary for one run. Value/Lower/Upper +// are expressed in Unit (nanoseconds for criterion latency benches); Lower and +// Upper are the confidence-interval bounds the runner reported. +type Measurement struct { + ID string `json:"id"` + Unit string `json:"unit"` + Value float64 `json:"value"` + Lower float64 `json:"lower"` + Upper float64 `json:"upper"` + Throughput float64 `json:"throughput,omitempty"` // elements/sec, when the bench declares it +} + +// Run is a full benchmark run captured at one commit. Timestamp is set by the +// caller (RFC3339) rather than at ingest, so replays and back-fills stay +// deterministic. +type Run struct { + Schema int `json:"schema"` + Tool string `json:"tool"` + Commit string `json:"commit"` + Branch string `json:"branch"` + Timestamp string `json:"timestamp"` + Measurements []Measurement `json:"measurements"` +} + +// Index returns the run's measurements keyed by benchmark ID. +func (r Run) Index() map[string]Measurement { + m := make(map[string]Measurement, len(r.Measurements)) + for _, x := range r.Measurements { + m[x.ID] = x + } + return m +} + +// Delta is a per-benchmark comparison of a current run against a baseline. +// PctChange is signed: positive means the current run is slower (a regression +// for latency units). +type Delta struct { + ID string + Unit string + Baseline float64 + Current float64 + PctChange float64 + HasBase bool // false when the baseline run had no matching benchmark (new bench) + Regressed bool // PctChange exceeded the report threshold +} + +// Compare diffs current against baseline, flagging any benchmark whose slowdown +// exceeds thresholdPct (a positive percentage). A benchmark absent from the +// baseline is reported with HasBase=false and never counts as a regression — +// it is new, not slower. The gate is advisory: Regressed is a hint for the +// human reviewer, not a build failure. +func Compare(current, baseline Run, thresholdPct float64) []Delta { + base := baseline.Index() + out := make([]Delta, 0, len(current.Measurements)) + for _, c := range current.Measurements { + d := Delta{ID: c.ID, Unit: c.Unit, Current: c.Value} + if b, ok := base[c.ID]; ok { + d.HasBase = true + d.Baseline = b.Value + if b.Value > 0 { + d.PctChange = (c.Value - b.Value) / b.Value * 100 + } + d.Regressed = d.PctChange > thresholdPct + } + out = append(out, d) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} diff --git a/internal/report/report.go b/internal/report/report.go new file mode 100644 index 0000000..df2ba5d --- /dev/null +++ b/internal/report/report.go @@ -0,0 +1,128 @@ +// Package report renders a comparison between a current run and its baseline +// into the Markdown that Cubit posts as a sticky PR comment. +// +// The comment is deliberately advisory: it summarizes what moved and flags +// slowdowns past the threshold, but it never asserts pass/fail. The human +// reviewer, with the trend dashboard for context, makes the go/no-go call. +package report + +import ( + "fmt" + "strings" + + "wardnet/cubit/internal/model" +) + +// Markdown renders the PR comment body for the given deltas. thresholdPct is +// echoed into the header so a reader knows what "⚠️" means for this run. +func Markdown(deltas []model.Delta, cur model.Run, thresholdPct float64, dashboardURL string) string { + var b strings.Builder + + // The logo is an absolute raw URL, not a repo-relative path: this comment is + // posted into the *consuming* repo's PR, where "assets/..." would resolve + // against that repo and 404. Pinned to cubit's default branch (not a release + // tag) so the image keeps resolving for consumers on an older cubit version. + fmt.Fprintf(&b, "## \"\" cubit — performance\n\n") + if cur.Commit != "" { + fmt.Fprintf(&b, "Commit `%s`", shortSHA(cur.Commit)) + if cur.Branch != "" { + fmt.Fprintf(&b, " on `%s`", cur.Branch) + } + b.WriteString(" · ") + } + fmt.Fprintf(&b, "%d benchmarks · flagging Δ > %.0f%% slower\n\n", len(deltas), thresholdPct) + + regressed := countRegressed(deltas) + newCount := countNew(deltas) + switch { + case regressed > 0: + fmt.Fprintf(&b, "⚠️ **%d benchmark(s) slower than baseline by more than %.0f%%** — review before merge.\n\n", regressed, thresholdPct) + default: + b.WriteString("✅ No benchmark exceeded the regression threshold.\n\n") + } + + b.WriteString("| Benchmark | Baseline | Current | Δ | |\n") + b.WriteString("|---|--:|--:|--:|:-:|\n") + for _, d := range deltas { + b.WriteString(row(d)) + } + b.WriteString("\n") + + if newCount > 0 { + fmt.Fprintf(&b, "_%d benchmark(s) are new (no baseline to compare)._\n\n", newCount) + } + if dashboardURL != "" { + fmt.Fprintf(&b, "📈 [Trend dashboard](%s) — history and variance band.\n", dashboardURL) + } + + return b.String() +} + +func row(d model.Delta) string { + if !d.HasBase { + return fmt.Sprintf("| `%s` | — | %s | _new_ | 🆕 |\n", d.ID, formatNs(d.Current)) + } + return fmt.Sprintf("| `%s` | %s | %s | %s | %s |\n", + d.ID, formatNs(d.Baseline), formatNs(d.Current), formatPct(d.PctChange), marker(d)) +} + +func marker(d model.Delta) string { + switch { + case d.Regressed: + return "⚠️" + case d.PctChange < -5: + return "🚀" // meaningfully faster + default: + return "" + } +} + +func countRegressed(deltas []model.Delta) int { + n := 0 + for _, d := range deltas { + if d.Regressed { + n++ + } + } + return n +} + +func countNew(deltas []model.Delta) int { + n := 0 + for _, d := range deltas { + if !d.HasBase { + n++ + } + } + return n +} + +// formatNs renders a nanosecond value with an adaptive unit, so a table mixing +// sub-microsecond cache lookups and millisecond pipeline runs stays readable. +func formatNs(ns float64) string { + switch { + case ns >= 1e9: + return fmt.Sprintf("%.2f s", ns/1e9) + case ns >= 1e6: + return fmt.Sprintf("%.2f ms", ns/1e6) + case ns >= 1e3: + return fmt.Sprintf("%.2f µs", ns/1e3) + default: + return fmt.Sprintf("%.1f ns", ns) + } +} + +func formatPct(p float64) string { + sign := "+" + if p < 0 { + sign = "" + } + return fmt.Sprintf("%s%.1f%%", sign, p) +} + +func shortSHA(s string) string { + if len(s) > 8 { + return s[:8] + } + return s +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..06b2342 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,71 @@ +#!/bin/sh +# install.sh — download and install the cubit CLI from GitHub releases. +# +# Usage: +# curl -fsSL https://github.com/wardnet/cubit/releases/latest/download/install.sh | sh +# +# Environment: +# CUBIT_VERSION version to install, e.g. 1.2.0 or v1.2.0 (default: latest) +# CUBIT_INSTALL_DIR install directory (default: $HOME/.local/bin) +set -eu + +REPO_URL="https://github.com/wardnet/cubit" +INSTALL_DIR="${CUBIT_INSTALL_DIR:-$HOME/.local/bin}" +VERSION="${CUBIT_VERSION:-latest}" + +if [ "$VERSION" = "latest" ]; then + # The releases/latest URL redirects to .../releases/tag/v. + LOCATION=$(curl -fsSI -o /dev/null -w '%{redirect_url}' "$REPO_URL/releases/latest") + VERSION="${LOCATION##*/}" +fi +VERSION="${VERSION#v}" +if [ -z "$VERSION" ]; then + echo "error: could not resolve cubit version" >&2 + exit 1 +fi + +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) +case "$ARCH" in + x86_64 | amd64) ARCH=amd64 ;; + aarch64 | arm64) ARCH=arm64 ;; + *) + echo "error: unsupported architecture: $ARCH" >&2 + exit 1 + ;; +esac + +ASSET="cubit_${VERSION}_${OS}_${ARCH}" +# Stage inside the install dir so the final rename is atomic and never +# truncates a running binary (mv across filesystems degrades to copy+truncate). +mkdir -p "$INSTALL_DIR" +STAGE=$(mktemp -d "$INSTALL_DIR/.cubit-install.XXXXXX") +trap 'rm -rf "$STAGE"' EXIT + +echo "downloading cubit v${VERSION} (${OS}/${ARCH})..." +curl -fsSL -o "$STAGE/cubit" "$REPO_URL/releases/download/v${VERSION}/${ASSET}" +curl -fsSL -o "$STAGE/checksums.txt" "$REPO_URL/releases/download/v${VERSION}/checksums.txt" + +WANT=$(awk -v asset="$ASSET" '$2 == asset { print $1 }' "$STAGE/checksums.txt") +if [ -z "$WANT" ]; then + echo "error: no checksum for $ASSET in checksums.txt" >&2 + exit 1 +fi +if command -v sha256sum >/dev/null 2>&1; then + GOT=$(sha256sum "$STAGE/cubit" | awk '{ print $1 }') +else + GOT=$(shasum -a 256 "$STAGE/cubit" | awk '{ print $1 }') +fi +if [ "$GOT" != "$WANT" ]; then + echo "error: checksum mismatch for $ASSET: got $GOT, want $WANT" >&2 + exit 1 +fi + +chmod 0755 "$STAGE/cubit" +mv "$STAGE/cubit" "$INSTALL_DIR/cubit" +echo "installed cubit v${VERSION} to $INSTALL_DIR/cubit" + +case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) echo "note: $INSTALL_DIR is not on your PATH" ;; +esac diff --git a/testdata/baseline.json b/testdata/baseline.json new file mode 100644 index 0000000..d589c16 --- /dev/null +++ b/testdata/baseline.json @@ -0,0 +1,24 @@ +{ + "schema": 1, + "tool": "criterion", + "commit": "0000000000000000000000000000000000000000", + "branch": "main", + "timestamp": "2026-07-18T00:00:00Z", + "measurements": [ + { + "id": "dns_cache/get", + "unit": "ns", + "value": 48.0, + "lower": 46.9, + "upper": 49.2 + }, + { + "id": "dns_resolution/cache_miss", + "unit": "ns", + "value": 1000000.0, + "lower": 985000.0, + "upper": 1015000.0, + "throughput": 800 + } + ] +} diff --git a/testdata/criterion/dns_cache/get/benchmark.json b/testdata/criterion/dns_cache/get/benchmark.json new file mode 100644 index 0000000..e306821 --- /dev/null +++ b/testdata/criterion/dns_cache/get/benchmark.json @@ -0,0 +1,9 @@ +{ + "group_id": "dns_cache", + "function_id": "get", + "value_str": null, + "throughput": [{ "per_iteration": 0, "unit": "Elements" }], + "full_id": "dns_cache/get", + "directory_name": "dns_cache/get", + "title": "dns_cache/get" +} diff --git a/testdata/criterion/dns_cache/get/new/estimates.json b/testdata/criterion/dns_cache/get/new/estimates.json new file mode 100644 index 0000000..18cf4f5 --- /dev/null +++ b/testdata/criterion/dns_cache/get/new/estimates.json @@ -0,0 +1,17 @@ +{ + "mean": { + "confidence_interval": { "confidence_level": 0.95, "lower_bound": 44.9, "upper_bound": 46.1 }, + "point_estimate": 45.4, + "standard_error": 0.31 + }, + "median": { + "confidence_interval": { "confidence_level": 0.95, "lower_bound": 44.1, "upper_bound": 46.8 }, + "point_estimate": 45.2, + "standard_error": 0.42 + }, + "std_dev": { + "confidence_interval": { "confidence_level": 0.95, "lower_bound": 1.8, "upper_bound": 3.1 }, + "point_estimate": 2.4, + "standard_error": 0.33 + } +} diff --git a/testdata/criterion/dns_resolution/cache_miss/benchmark.json b/testdata/criterion/dns_resolution/cache_miss/benchmark.json new file mode 100644 index 0000000..8db661a --- /dev/null +++ b/testdata/criterion/dns_resolution/cache_miss/benchmark.json @@ -0,0 +1,9 @@ +{ + "group_id": "dns_resolution", + "function_id": "cache_miss", + "value_str": null, + "throughput": [{ "per_iteration": 800, "unit": "Elements" }], + "full_id": "dns_resolution/cache_miss", + "directory_name": "dns_resolution/cache_miss", + "title": "dns_resolution/cache_miss" +} diff --git a/testdata/criterion/dns_resolution/cache_miss/new/estimates.json b/testdata/criterion/dns_resolution/cache_miss/new/estimates.json new file mode 100644 index 0000000..fb4d841 --- /dev/null +++ b/testdata/criterion/dns_resolution/cache_miss/new/estimates.json @@ -0,0 +1,17 @@ +{ + "mean": { + "confidence_interval": { "confidence_level": 0.95, "lower_bound": 1230000.0, "upper_bound": 1275000.0 }, + "point_estimate": 1252000.0, + "standard_error": 11200.0 + }, + "median": { + "confidence_interval": { "confidence_level": 0.95, "lower_bound": 1235000.0, "upper_bound": 1268000.0 }, + "point_estimate": 1250000.0, + "standard_error": 9800.0 + }, + "std_dev": { + "confidence_interval": { "confidence_level": 0.95, "lower_bound": 42000.0, "upper_bound": 71000.0 }, + "point_estimate": 55000.0, + "standard_error": 7300.0 + } +} diff --git a/web/.yarnrc.yml b/web/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/web/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..13fad88 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + cubit — performance + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..d3be386 --- /dev/null +++ b/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "cubit-dashboard", + "private": true, + "version": "0.0.0", + "type": "module", + "packageManager": "yarn@4.17.1", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.1.0" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..d1897ee --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; +import type { Dashboard } from "./types"; +import { loadData } from "./data"; +import { LineChart } from "./LineChart"; +import logo from "./assets/cubit-logo.png"; + +export function App() { + const [data, setData] = useState(undefined); + + useEffect(() => { + loadData().then(setData); + }, []); + + if (data === undefined) { + return
Loading…
; + } + if (data === null || data.benchmarks.length === 0) { + return ( +
+

cubit

+

+ No benchmark history yet. Record a run on the default branch to start + the trend. +

+
+ ); + } + + return ( +
+
+

+ cubit — performance +

+

+ {data.benchmarks.length} benchmarks · branch {data.branch} · + generated {new Date(data.generatedAt).toISOString().replace("T", " ").slice(0, 16)} +

+
+
+ {data.benchmarks.map((b) => ( + + ))} +
+
+ ); +} diff --git a/web/src/LineChart.tsx b/web/src/LineChart.tsx new file mode 100644 index 0000000..7e38151 --- /dev/null +++ b/web/src/LineChart.tsx @@ -0,0 +1,82 @@ +import type { BenchSeries } from "./types"; +import { formatValue } from "./data"; + +const W = 720; +const H = 240; +const PAD = { top: 16, right: 16, bottom: 28, left: 64 }; + +// LineChart draws one benchmark's history: a median line with the +// confidence-interval band shaded behind it, so a reviewer can see whether a +// new point sits inside the run-to-run noise or steps out of it. +export function LineChart({ series }: { series: BenchSeries }) { + const pts = series.points; + const plotW = W - PAD.left - PAD.right; + const plotH = H - PAD.top - PAD.bottom; + + if (pts.length === 0) { + return
no data
; + } + + const lows = pts.map((p) => p.lower); + const highs = pts.map((p) => p.upper); + let yMin = Math.min(...lows); + let yMax = Math.max(...highs); + if (yMin === yMax) { + yMin *= 0.95; + yMax *= 1.05; + } + const span = yMax - yMin || 1; + + const x = (i: number) => + PAD.left + (pts.length === 1 ? plotW / 2 : (i / (pts.length - 1)) * plotW); + const y = (v: number) => PAD.top + plotH - ((v - yMin) / span) * plotH; + + const line = pts.map((p, i) => `${x(i)},${y(p.value)}`).join(" "); + const band = + pts.map((p, i) => `${x(i)},${y(p.upper)}`).join(" ") + + " " + + pts + .map((p, i) => `${x(pts.length - 1 - i)},${y(pts[pts.length - 1 - i].lower)}`) + .join(" "); + + const last = pts[pts.length - 1]; + + return ( +
+
+ {series.id} + {formatValue(last.value)} +
+ + {[0, 0.25, 0.5, 0.75, 1].map((f) => { + const gy = PAD.top + plotH * f; + const val = yMax - span * f; + return ( + + + + {formatValue(val)} + + + ); + })} + + + {pts.map((p, i) => ( + + + {p.shortCommit} · {formatValue(p.value)} ({new Date(p.timestamp).toISOString().slice(0, 10)}) + + + ))} + {pts.map((p, i) => + i === 0 || i === pts.length - 1 || pts.length <= 6 ? ( + + {p.shortCommit} + + ) : null, + )} + +
+ ); +} diff --git a/web/src/assets/cubit-logo.png b/web/src/assets/cubit-logo.png new file mode 100644 index 0000000..c8a7254 Binary files /dev/null and b/web/src/assets/cubit-logo.png differ diff --git a/web/src/data.ts b/web/src/data.ts new file mode 100644 index 0000000..6e89da3 --- /dev/null +++ b/web/src/data.ts @@ -0,0 +1,34 @@ +import type { Dashboard } from "./types"; + +declare global { + interface Window { + __CUBIT_DATA__?: Dashboard | null; + } +} + +// loadData reads the dashboard payload. The Go side injects it as a global for +// the standalone/served page; a dev server (`yarn dev`) falls back to fetching +// ./data.json. +export async function loadData(): Promise { + if (window.__CUBIT_DATA__) { + return window.__CUBIT_DATA__; + } + try { + const res = await fetch("./data.json"); + if (res.ok) { + return (await res.json()) as Dashboard; + } + } catch { + /* no dev data available */ + } + return null; +} + +// formatValue renders a nanosecond metric with an adaptive unit, matching the +// PR-comment formatter so the dashboard and comment read the same. +export function formatValue(ns: number): string { + if (ns >= 1e9) return `${(ns / 1e9).toFixed(2)} s`; + if (ns >= 1e6) return `${(ns / 1e6).toFixed(2)} ms`; + if (ns >= 1e3) return `${(ns / 1e3).toFixed(2)} µs`; + return `${ns.toFixed(1)} ns`; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..9891129 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..c181cea --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,134 @@ +:root { + --bg: #ffffff; + --fg: #1a1a1a; + --muted: #6b7280; + --grid: #e5e7eb; + --line: #2563eb; + --band: rgba(37, 99, 235, 0.15); + --card: #f9fafb; + --border: #e5e7eb; + color-scheme: light dark; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d1117; + --fg: #e6edf3; + --muted: #8b949e; + --grid: #21262d; + --line: #58a6ff; + --band: rgba(88, 166, 255, 0.18); + --card: #161b22; + --border: #30363d; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +.wrap { + max-width: 1200px; + margin: 0 auto; + padding: 32px 24px 64px; +} + +h1 { + font-size: 20px; + margin: 0 0 4px; + display: flex; + align-items: center; + gap: 8px; +} + +.logo { + height: 24px; + width: auto; +} + +.muted { + color: var(--muted); + margin: 0 0 24px; +} + +code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.92em; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + gap: 20px; +} + +.chart { + margin: 0; + padding: 12px 12px 4px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 10px; +} + +.chart figcaption { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 4px; +} + +.chart-id { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; +} + +.chart-last { + font-variant-numeric: tabular-nums; + color: var(--muted); +} + +.chart svg { + width: 100%; + height: auto; + overflow: visible; +} + +.grid line.grid, +.chart .grid { + stroke: var(--grid); + stroke-width: 1; +} + +.band { + fill: var(--band); + stroke: none; +} + +.line { + stroke: var(--line); + stroke-width: 2; +} + +.dot { + fill: var(--line); +} + +.ytick, +.xtick { + fill: var(--muted); + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.chart-empty { + color: var(--muted); + padding: 24px; + text-align: center; +} diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..2ae9a32 --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,23 @@ +// The dashboard data shape, produced by the Go side (internal/dashboard) from +// the runs recorded on the cubit-state branch. One BenchSeries per benchmark +// id; points are ordered oldest-first along the main branch. +export interface Point { + commit: string; + shortCommit: string; + timestamp: string; + value: number; + lower: number; + upper: number; +} + +export interface BenchSeries { + id: string; + unit: string; + points: Point[]; +} + +export interface Dashboard { + generatedAt: string; + branch: string; + benchmarks: BenchSeries[]; +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..3f6dd8c --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..70742c2 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +// viteSingleFile inlines JS + CSS into a single index.html, so the dashboard +// works as a standalone file — served locally by `cubit serve` or downloaded as +// a CI artifact and opened over file:// (no external requests, no CORS). +export default defineConfig({ + plugins: [react(), viteSingleFile()], + build: { + outDir: "dist", + target: "es2020", + assetsInlineLimit: 100_000_000, + }, +}); diff --git a/web/yarn.lock b/web/yarn.lock new file mode 100644 index 0000000..4fb02f5 --- /dev/null +++ b/web/yarn.lock @@ -0,0 +1,1430 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 10 + cacheKey: 10c0 + +"@babel/code-frame@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/code-frame@npm:7.29.7" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.29.7" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.1.1" + checksum: 10c0/169fc2080169a40c1760155eaaaf739bcb882df0bec76a83adbda5493645bc17270a3434b8848c494b1933e96fe1d147370001e3cda09a39f43ae30f08ef2069 + languageName: node + linkType: hard + +"@babel/compat-data@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/compat-data@npm:7.29.7" + checksum: 10c0/47913f05e08a45a1c9df38c02b4b49e391005085b489432647a1abe112e5d9c75e3be8ea5972b7f6da4ec5d1339922ceb9ea02b8a25d4ed1cb8636e5261f344e + languageName: node + linkType: hard + +"@babel/core@npm:^7.28.0": + version: 7.29.7 + resolution: "@babel/core@npm:7.29.7" + dependencies: + "@babel/code-frame": "npm:^7.29.7" + "@babel/generator": "npm:^7.29.7" + "@babel/helper-compilation-targets": "npm:^7.29.7" + "@babel/helper-module-transforms": "npm:^7.29.7" + "@babel/helpers": "npm:^7.29.7" + "@babel/parser": "npm:^7.29.7" + "@babel/template": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + "@jridgewell/remapping": "npm:^2.3.5" + convert-source-map: "npm:^2.0.0" + debug: "npm:^4.1.0" + gensync: "npm:^1.0.0-beta.2" + json5: "npm:^2.2.3" + semver: "npm:^6.3.1" + checksum: 10c0/112fb09c24de7a1de64d1de2c31fe65c4e6af4cb2fb6e6d99ea5373e6fc51e75b88581c0efae4c4c68f119a02a988c7106e95011a41530a2fb8ed793c7eaa07b + languageName: node + linkType: hard + +"@babel/generator@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/generator@npm:7.29.7" + dependencies: + "@babel/parser": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + jsesc: "npm:^3.0.2" + checksum: 10c0/9bf72b01b5bd0ea5b1288a0e37dbd360bff2f2b1ce73342c0d40fb3db2ec3dc004ada5ffa925c5e12939a416eed59e600d562b8ecd938ce0d27dfd0eb6c6c2b7 + languageName: node + linkType: hard + +"@babel/helper-compilation-targets@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-compilation-targets@npm:7.29.7" + dependencies: + "@babel/compat-data": "npm:^7.29.7" + "@babel/helper-validator-option": "npm:^7.29.7" + browserslist: "npm:^4.24.0" + lru-cache: "npm:^5.1.1" + semver: "npm:^6.3.1" + checksum: 10c0/4c15fd4c69a0a7047799a28a88460c19cede0a0ee8af994ea169114986f4af48b92c7393a4a3fee0456c11a656eece3448a6ed06354453d6c27cccf17195453b + languageName: node + linkType: hard + +"@babel/helper-globals@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-globals@npm:7.29.7" + checksum: 10c0/f38417c40b1129a1b2b519ca961b9040c8827d1444fd74068702286b91b77089431dc76b6b9d5c1496e5da2a4f3ad329c6946e688ba3fa0d1d0b3d2b4f34f36a + languageName: node + linkType: hard + +"@babel/helper-module-imports@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-module-imports@npm:7.29.7" + dependencies: + "@babel/traverse": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10c0/6adf60d97356027413342a092f818d9678c4f5caff716a33e3284b5ae14e47a9e88059d421dde4ee4894691260039a12602c0e7becadc175602194b40dfa345d + languageName: node + linkType: hard + +"@babel/helper-module-transforms@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-module-transforms@npm:7.29.7" + dependencies: + "@babel/helper-module-imports": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + "@babel/traverse": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10c0/ee5a2172c24a42be696836f4b0d947489c9729d8adf5821885cf77d1ad5333e3c447368e9a71f67df1099570490553dccf9f888ef0a92a48aa63cb086bd8c7e1 + languageName: node + linkType: hard + +"@babel/helper-plugin-utils@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-plugin-utils@npm:7.29.7" + checksum: 10c0/380477a06133274a2759f9355929cb60a95e8b8fee624a1ae1fa349e1d1645b89daca456f72833f6d1062bffa12ee4271c5bf0cc5a61c0166cdc24c7591e2408 + languageName: node + linkType: hard + +"@babel/helper-string-parser@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-string-parser@npm:7.29.7" + checksum: 10c0/194bc0f1716e396d5ffde56ad6119745fb9557662c98611590e5e454906783a4ccb21ce93056b8eb69a4909044834e45d96e50ac695bbe9e3221648fe033c06c + languageName: node + linkType: hard + +"@babel/helper-validator-identifier@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-validator-identifier@npm:7.29.7" + checksum: 10c0/4795354e7ae0dcafa72de1cd04ec51252dc1498517170beaf019e03effc5b7bf13c6b21a3949a77e07b8125be7f106ed1131350d8ebd4566ae874094a726d62b + languageName: node + linkType: hard + +"@babel/helper-validator-option@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helper-validator-option@npm:7.29.7" + checksum: 10c0/d2a06c6d0ac40ba4a2f219fc2cab249c7a94bacdb2686273b7f9598571c908809b48468ff588915a346e6cc7296f60b581023d1d498b747fed06f779d335c2cc + languageName: node + linkType: hard + +"@babel/helpers@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/helpers@npm:7.29.7" + dependencies: + "@babel/template": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10c0/218e8d10953647c9f44775f5a022b227a182674853b5ea8631889deb7e1a3e4bc870388aaecf59bb8bd92a87f9a96220ed3f70a35bffec6bcf9169ecb67891ac + languageName: node + linkType: hard + +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/parser@npm:7.29.7" + dependencies: + "@babel/types": "npm:^7.29.7" + bin: + parser: ./bin/babel-parser.js + checksum: 10c0/65133038f80b54a714d6027cb77cee3f9a6b5c4c6842ce674301e13947cbcbfa8055e63acaf1b84c085d34226a14425b2c2b97b829e0e226d2e8f1299942a51d + languageName: node + linkType: hard + +"@babel/plugin-transform-react-jsx-self@npm:^7.27.1": + version: 7.29.7 + resolution: "@babel/plugin-transform-react-jsx-self@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/288995f0fd0d61ab740a315fb56c8255eb87dd4a4ac2ac7d0fdd4ce173c3878200141e80da2db0e598c7b2a71e74e604afdbb4c8e14ae6e0527ce0b6294c03da + languageName: node + linkType: hard + +"@babel/plugin-transform-react-jsx-source@npm:^7.27.1": + version: 7.29.7 + resolution: "@babel/plugin-transform-react-jsx-source@npm:7.29.7" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.29.7" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/a121899631e6d99b9e1b276acf736dbb77948a31f8eeeae67b89c8a4ab0f05e51ba64544baa06c286a2b9944f227244e15aac464e2313d286d0511fe51e27975 + languageName: node + linkType: hard + +"@babel/template@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/template@npm:7.29.7" + dependencies: + "@babel/code-frame": "npm:^7.29.7" + "@babel/parser": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + checksum: 10c0/8bb7f900dcab0e9e1c5ffbc33ca10e0d26b7b2e2ca804becb73ee771b9c4ed6e2908a4ae4a14c08560febb45d2b6b9a173955e42ad404d05f8b04840a14d9c58 + languageName: node + linkType: hard + +"@babel/traverse@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/traverse@npm:7.29.7" + dependencies: + "@babel/code-frame": "npm:^7.29.7" + "@babel/generator": "npm:^7.29.7" + "@babel/helper-globals": "npm:^7.29.7" + "@babel/parser": "npm:^7.29.7" + "@babel/template": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + debug: "npm:^4.3.1" + checksum: 10c0/e256a1fbdb956555b76f3c285b1e453f6bedec8b3afb61751d99d933efd11c7d79caf5ddf2493570058a9f7deaa1b48324380d7c1aa1443fd9508becbf56331a + languageName: node + linkType: hard + +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.28.2, @babel/types@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/types@npm:7.29.7" + dependencies: + "@babel/helper-string-parser": "npm:^7.29.7" + "@babel/helper-validator-identifier": "npm:^7.29.7" + checksum: 10c0/b6623994c69717fa27294f5fa46d59140338e2d86c6c1c13085c84ef7d53086ee357fbf4fe9abe3dd3da75734dc77c4c0df2f90fb29e667558bb3b3fb705e88f + languageName: node + linkType: hard + +"@esbuild/aix-ppc64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/aix-ppc64@npm:0.25.12" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/android-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/android-arm64@npm:0.25.12" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/android-arm@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/android-arm@npm:0.25.12" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@esbuild/android-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/android-x64@npm:0.25.12" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/darwin-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/darwin-arm64@npm:0.25.12" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/darwin-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/darwin-x64@npm:0.25.12" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/freebsd-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/freebsd-arm64@npm:0.25.12" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/freebsd-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/freebsd-x64@npm:0.25.12" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/linux-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-arm64@npm:0.25.12" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/linux-arm@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-arm@npm:0.25.12" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@esbuild/linux-ia32@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-ia32@npm:0.25.12" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/linux-loong64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-loong64@npm:0.25.12" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + +"@esbuild/linux-mips64el@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-mips64el@npm:0.25.12" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + +"@esbuild/linux-ppc64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-ppc64@npm:0.25.12" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/linux-riscv64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-riscv64@npm:0.25.12" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"@esbuild/linux-s390x@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-s390x@npm:0.25.12" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + +"@esbuild/linux-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-x64@npm:0.25.12" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/netbsd-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/netbsd-arm64@npm:0.25.12" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/netbsd-x64@npm:0.25.12" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openbsd-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/openbsd-arm64@npm:0.25.12" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openbsd-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/openbsd-x64@npm:0.25.12" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openharmony-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/openharmony-arm64@npm:0.25.12" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/sunos-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/sunos-x64@npm:0.25.12" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/win32-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/win32-arm64@npm:0.25.12" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/win32-ia32@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/win32-ia32@npm:0.25.12" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/win32-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/win32-x64@npm:0.25.12" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@isaacs/fs-minipass@npm:^4.0.0": + version: 4.0.1 + resolution: "@isaacs/fs-minipass@npm:4.0.1" + dependencies: + minipass: "npm:^7.0.4" + checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2 + languageName: node + linkType: hard + +"@jridgewell/gen-mapping@npm:^0.3.12, @jridgewell/gen-mapping@npm:^0.3.5": + version: 0.3.13 + resolution: "@jridgewell/gen-mapping@npm:0.3.13" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.0" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/9a7d65fb13bd9aec1fbab74cda08496839b7e2ceb31f5ab922b323e94d7c481ce0fc4fd7e12e2610915ed8af51178bdc61e168e92a8c8b8303b030b03489b13b + languageName: node + linkType: hard + +"@jridgewell/remapping@npm:^2.3.5": + version: 2.3.5 + resolution: "@jridgewell/remapping@npm:2.3.5" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/3de494219ffeb2c5c38711d0d7bb128097edf91893090a2dbc8ee0b55d092bb7347b1fd0f478486c5eab010e855c73927b1666f2107516d472d24a73017d1194 + languageName: node + linkType: hard + +"@jridgewell/resolve-uri@npm:^3.1.0": + version: 3.1.2 + resolution: "@jridgewell/resolve-uri@npm:3.1.2" + checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e + languageName: node + linkType: hard + +"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0": + version: 1.5.5 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" + checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 + languageName: node + linkType: hard + +"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.28": + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10c0/4b30ec8cd56c5fd9a661f088230af01e0c1a3888d11ffb6b47639700f71225be21d1f7e168048d6d4f9449207b978a235c07c8f15c07705685d16dc06280e9d9 + languageName: node + linkType: hard + +"@rolldown/pluginutils@npm:1.0.0-beta.27": + version: 1.0.0-beta.27 + resolution: "@rolldown/pluginutils@npm:1.0.0-beta.27" + checksum: 10c0/9658f235b345201d4f6bfb1f32da9754ca164f892d1cb68154fe5f53c1df42bd675ecd409836dff46884a7847d6c00bdc38af870f7c81e05bba5c2645eb4ab9c + languageName: node + linkType: hard + +"@rollup/rollup-android-arm-eabi@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.62.2" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@rollup/rollup-android-arm64@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-android-arm64@npm:4.62.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-arm64@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-darwin-arm64@npm:4.62.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-x64@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-darwin-x64@npm:4.62.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-freebsd-arm64@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.62.2" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-freebsd-x64@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-freebsd-x64@npm:4.62.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-gnueabihf@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.62.2" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-musleabihf@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.62.2" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm64-gnu@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.62.2" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm64-musl@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.62.2" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-loong64-gnu@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.62.2" + conditions: os=linux & cpu=loong64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-loong64-musl@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-linux-loong64-musl@npm:4.62.2" + conditions: os=linux & cpu=loong64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-ppc64-gnu@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.62.2" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-ppc64-musl@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.62.2" + conditions: os=linux & cpu=ppc64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-riscv64-gnu@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.62.2" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-riscv64-musl@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.62.2" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-s390x-gnu@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.62.2" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-x64-gnu@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.62.2" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-x64-musl@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.62.2" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-openbsd-x64@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-openbsd-x64@npm:4.62.2" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-openharmony-arm64@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-openharmony-arm64@npm:4.62.2" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-arm64-msvc@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.62.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-ia32-msvc@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.62.2" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@rollup/rollup-win32-x64-gnu@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-win32-x64-gnu@npm:4.62.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-x64-msvc@npm:4.62.2": + version: 4.62.2 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.62.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@types/babel__core@npm:^7.20.5": + version: 7.20.5 + resolution: "@types/babel__core@npm:7.20.5" + dependencies: + "@babel/parser": "npm:^7.20.7" + "@babel/types": "npm:^7.20.7" + "@types/babel__generator": "npm:*" + "@types/babel__template": "npm:*" + "@types/babel__traverse": "npm:*" + checksum: 10c0/bdee3bb69951e833a4b811b8ee9356b69a61ed5b7a23e1a081ec9249769117fa83aaaf023bb06562a038eb5845155ff663e2d5c75dd95c1d5ccc91db012868ff + languageName: node + linkType: hard + +"@types/babel__generator@npm:*": + version: 7.27.0 + resolution: "@types/babel__generator@npm:7.27.0" + dependencies: + "@babel/types": "npm:^7.0.0" + checksum: 10c0/9f9e959a8792df208a9d048092fda7e1858bddc95c6314857a8211a99e20e6830bdeb572e3587ae8be5429e37f2a96fcf222a9f53ad232f5537764c9e13a2bbd + languageName: node + linkType: hard + +"@types/babel__template@npm:*": + version: 7.4.4 + resolution: "@types/babel__template@npm:7.4.4" + dependencies: + "@babel/parser": "npm:^7.1.0" + "@babel/types": "npm:^7.0.0" + checksum: 10c0/cc84f6c6ab1eab1427e90dd2b76ccee65ce940b778a9a67be2c8c39e1994e6f5bbc8efa309f6cea8dc6754994524cd4d2896558df76d92e7a1f46ecffee7112b + languageName: node + linkType: hard + +"@types/babel__traverse@npm:*": + version: 7.28.0 + resolution: "@types/babel__traverse@npm:7.28.0" + dependencies: + "@babel/types": "npm:^7.28.2" + checksum: 10c0/b52d7d4e8fc6a9018fe7361c4062c1c190f5778cf2466817cb9ed19d69fbbb54f9a85ffedeb748ed8062d2cf7d4cc088ee739848f47c57740de1c48cbf0d0994 + languageName: node + linkType: hard + +"@types/estree@npm:1.0.9": + version: 1.0.9 + resolution: "@types/estree@npm:1.0.9" + checksum: 10c0/3ad3286ca2988cd550dafb8f2ad599c8474868e954fa601a36655bdfefd8039f7c714b8c1c7f2ae219ffbd58bd4660e66fa7479a0120fc02d4777057d4865387 + languageName: node + linkType: hard + +"@vitejs/plugin-react@npm:^4.3.4": + version: 4.7.0 + resolution: "@vitejs/plugin-react@npm:4.7.0" + dependencies: + "@babel/core": "npm:^7.28.0" + "@babel/plugin-transform-react-jsx-self": "npm:^7.27.1" + "@babel/plugin-transform-react-jsx-source": "npm:^7.27.1" + "@rolldown/pluginutils": "npm:1.0.0-beta.27" + "@types/babel__core": "npm:^7.20.5" + react-refresh: "npm:^0.17.0" + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + checksum: 10c0/692f23960972879485d647713663ec299c478222c96567d60285acf7c7dc5c178e71abfe9d2eefddef1eeb01514dacbc2ed68aad84628debf9c7116134734253 + languageName: node + linkType: hard + +"abbrev@npm:^5.0.0": + version: 5.0.0 + resolution: "abbrev@npm:5.0.0" + checksum: 10c0/8e88f5c798ea4562d28c5a3e9ad69e3879890bc5d695d8f2dffb8609be4c890aacc8f80ef4553fdd2c6a62d70c2ce8bc57b38074e383beb7487bdafa9ed42ea5 + languageName: node + linkType: hard + +"baseline-browser-mapping@npm:^2.10.42": + version: 2.10.43 + resolution: "baseline-browser-mapping@npm:2.10.43" + bin: + baseline-browser-mapping: dist/cli.cjs + checksum: 10c0/08a9e585fe0b1672a0a41026579d41debbaff2c1ceffc9efe510b47713b9d78296846d7a86f0d74d8f64af0088fdebd715f5ea6cb88d6fe1d57f8fafa686b3b5 + languageName: node + linkType: hard + +"braces@npm:^3.0.3": + version: 3.0.3 + resolution: "braces@npm:3.0.3" + dependencies: + fill-range: "npm:^7.1.1" + checksum: 10c0/7c6dfd30c338d2997ba77500539227b9d1f85e388a5f43220865201e407e076783d0881f2d297b9f80951b4c957fcf0b51c1d2d24227631643c3f7c284b0aa04 + languageName: node + linkType: hard + +"browserslist@npm:^4.24.0": + version: 4.28.6 + resolution: "browserslist@npm:4.28.6" + dependencies: + baseline-browser-mapping: "npm:^2.10.42" + caniuse-lite: "npm:^1.0.30001803" + electron-to-chromium: "npm:^1.5.389" + node-releases: "npm:^2.0.51" + update-browserslist-db: "npm:^1.2.3" + bin: + browserslist: cli.js + checksum: 10c0/259e3c2e0e59daf1fab0889303d397b35505c1c910e503a329990c4f6c0e589c0959fcd2627487311e4a94d7b9e2a9b1fa02875592f5c98b8e9e03e0ccd1e3ea + languageName: node + linkType: hard + +"caniuse-lite@npm:^1.0.30001803": + version: 1.0.30001806 + resolution: "caniuse-lite@npm:1.0.30001806" + checksum: 10c0/9442c8afff0968e9b2ce0104a7340c1ce4a5c09f469ccb9eef10d25712ea0afe542486e5318c697c70dd502f988cfc04eaa1c9438c92ac3bcf4d708a2a17bee1 + languageName: node + linkType: hard + +"chownr@npm:^3.0.0": + version: 3.0.0 + resolution: "chownr@npm:3.0.0" + checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10 + languageName: node + linkType: hard + +"convert-source-map@npm:^2.0.0": + version: 2.0.0 + resolution: "convert-source-map@npm:2.0.0" + checksum: 10c0/8f2f7a27a1a011cc6cc88cc4da2d7d0cfa5ee0369508baae3d98c260bb3ac520691464e5bbe4ae7cdf09860c1d69ecc6f70c63c6e7c7f7e3f18ec08484dc7d9b + languageName: node + linkType: hard + +"cubit-dashboard@workspace:.": + version: 0.0.0-use.local + resolution: "cubit-dashboard@workspace:." + dependencies: + "@vitejs/plugin-react": "npm:^4.3.4" + react: "npm:^19.0.0" + react-dom: "npm:^19.0.0" + typescript: "npm:^5.7.0" + vite: "npm:^6.0.0" + vite-plugin-singlefile: "npm:^2.1.0" + languageName: unknown + linkType: soft + +"debug@npm:^4.1.0, debug@npm:^4.3.1": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 + languageName: node + linkType: hard + +"electron-to-chromium@npm:^1.5.389": + version: 1.5.393 + resolution: "electron-to-chromium@npm:1.5.393" + checksum: 10c0/d8ef05aa8511bafb6b60506683dddbb968829f966af2aec582f06c208dc3598d7c5fc68b69ea2303befde1177637c19e53065a0fb7f47ca09c4928436c5768e7 + languageName: node + linkType: hard + +"env-paths@npm:^2.2.0": + version: 2.2.1 + resolution: "env-paths@npm:2.2.1" + checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4 + languageName: node + linkType: hard + +"esbuild@npm:^0.25.0": + version: 0.25.12 + resolution: "esbuild@npm:0.25.12" + dependencies: + "@esbuild/aix-ppc64": "npm:0.25.12" + "@esbuild/android-arm": "npm:0.25.12" + "@esbuild/android-arm64": "npm:0.25.12" + "@esbuild/android-x64": "npm:0.25.12" + "@esbuild/darwin-arm64": "npm:0.25.12" + "@esbuild/darwin-x64": "npm:0.25.12" + "@esbuild/freebsd-arm64": "npm:0.25.12" + "@esbuild/freebsd-x64": "npm:0.25.12" + "@esbuild/linux-arm": "npm:0.25.12" + "@esbuild/linux-arm64": "npm:0.25.12" + "@esbuild/linux-ia32": "npm:0.25.12" + "@esbuild/linux-loong64": "npm:0.25.12" + "@esbuild/linux-mips64el": "npm:0.25.12" + "@esbuild/linux-ppc64": "npm:0.25.12" + "@esbuild/linux-riscv64": "npm:0.25.12" + "@esbuild/linux-s390x": "npm:0.25.12" + "@esbuild/linux-x64": "npm:0.25.12" + "@esbuild/netbsd-arm64": "npm:0.25.12" + "@esbuild/netbsd-x64": "npm:0.25.12" + "@esbuild/openbsd-arm64": "npm:0.25.12" + "@esbuild/openbsd-x64": "npm:0.25.12" + "@esbuild/openharmony-arm64": "npm:0.25.12" + "@esbuild/sunos-x64": "npm:0.25.12" + "@esbuild/win32-arm64": "npm:0.25.12" + "@esbuild/win32-ia32": "npm:0.25.12" + "@esbuild/win32-x64": "npm:0.25.12" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/c205357531423220a9de8e1e6c6514242bc9b1666e762cd67ccdf8fdfdc3f1d0bd76f8d9383958b97ad4c953efdb7b6e8c1f9ca5951cd2b7c5235e8755b34a6b + languageName: node + linkType: hard + +"escalade@npm:^3.2.0": + version: 3.2.0 + resolution: "escalade@npm:3.2.0" + checksum: 10c0/ced4dd3a78e15897ed3be74e635110bbf3b08877b0a41be50dcb325ee0e0b5f65fc2d50e9845194d7c4633f327e2e1c6cce00a71b617c5673df0374201d67f65 + languageName: node + linkType: hard + +"exponential-backoff@npm:^3.1.1": + version: 3.1.3 + resolution: "exponential-backoff@npm:3.1.3" + checksum: 10c0/77e3ae682b7b1f4972f563c6dbcd2b0d54ac679e62d5d32f3e5085feba20483cf28bd505543f520e287a56d4d55a28d7874299941faf637e779a1aa5994d1267 + languageName: node + linkType: hard + +"fdir@npm:^6.4.4, fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f + languageName: node + linkType: hard + +"fill-range@npm:^7.1.1": + version: 7.1.1 + resolution: "fill-range@npm:7.1.1" + dependencies: + to-regex-range: "npm:^5.0.1" + checksum: 10c0/b75b691bbe065472f38824f694c2f7449d7f5004aa950426a2c28f0306c60db9b880c0b0e4ed819997ffb882d1da02cfcfc819bddc94d71627f5269682edf018 + languageName: node + linkType: hard + +"fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/a1f0c44595123ed717febbc478aa952e47adfc28e2092be66b8ab1635147254ca6cfe1df792a8997f22716d4cbafc73309899ff7bfac2ac3ad8cf2e4ecc3ec60 + conditions: os=darwin + languageName: node + linkType: hard + +"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": + version: 2.3.3 + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + +"gensync@npm:^1.0.0-beta.2": + version: 1.0.0-beta.2 + resolution: "gensync@npm:1.0.0-beta.2" + checksum: 10c0/782aba6cba65b1bb5af3b095d96249d20edbe8df32dbf4696fd49be2583faf676173bf4809386588828e4dd76a3354fcbeb577bab1c833ccd9fc4577f26103f8 + languageName: node + linkType: hard + +"graceful-fs@npm:^4.2.6": + version: 4.2.11 + resolution: "graceful-fs@npm:4.2.11" + checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 + languageName: node + linkType: hard + +"is-number@npm:^7.0.0": + version: 7.0.0 + resolution: "is-number@npm:7.0.0" + checksum: 10c0/b4686d0d3053146095ccd45346461bc8e53b80aeb7671cc52a4de02dbbf7dc0d1d2a986e2fe4ae206984b4d34ef37e8b795ebc4f4295c978373e6575e295d811 + languageName: node + linkType: hard + +"isexe@npm:^4.0.0": + version: 4.0.0 + resolution: "isexe@npm:4.0.0" + checksum: 10c0/5884815115bceac452877659a9c7726382531592f43dc29e5d48b7c4100661aed54018cb90bd36cb2eaeba521092570769167acbb95c18d39afdccbcca06c5ce + languageName: node + linkType: hard + +"js-tokens@npm:^4.0.0": + version: 4.0.0 + resolution: "js-tokens@npm:4.0.0" + checksum: 10c0/e248708d377aa058eacf2037b07ded847790e6de892bbad3dac0abba2e759cb9f121b00099a65195616badcb6eca8d14d975cb3e89eb1cfda644756402c8aeed + languageName: node + linkType: hard + +"jsesc@npm:^3.0.2": + version: 3.1.0 + resolution: "jsesc@npm:3.1.0" + bin: + jsesc: bin/jsesc + checksum: 10c0/531779df5ec94f47e462da26b4cbf05eb88a83d9f08aac2ba04206508fc598527a153d08bd462bae82fc78b3eaa1a908e1a4a79f886e9238641c4cdefaf118b1 + languageName: node + linkType: hard + +"json5@npm:^2.2.3": + version: 2.2.3 + resolution: "json5@npm:2.2.3" + bin: + json5: lib/cli.js + checksum: 10c0/5a04eed94810fa55c5ea138b2f7a5c12b97c3750bc63d11e511dcecbfef758003861522a070c2272764ee0f4e3e323862f386945aeb5b85b87ee43f084ba586c + languageName: node + linkType: hard + +"lru-cache@npm:^5.1.1": + version: 5.1.1 + resolution: "lru-cache@npm:5.1.1" + dependencies: + yallist: "npm:^3.0.2" + checksum: 10c0/89b2ef2ef45f543011e38737b8a8622a2f8998cddf0e5437174ef8f1f70a8b9d14a918ab3e232cb3ba343b7abddffa667f0b59075b2b80e6b4d63c3de6127482 + languageName: node + linkType: hard + +"micromatch@npm:^4.0.8": + version: 4.0.8 + resolution: "micromatch@npm:4.0.8" + dependencies: + braces: "npm:^3.0.3" + picomatch: "npm:^2.3.1" + checksum: 10c0/166fa6eb926b9553f32ef81f5f531d27b4ce7da60e5baf8c021d043b27a388fb95e46a8038d5045877881e673f8134122b59624d5cecbd16eb50a42e7a6b5ca8 + languageName: node + linkType: hard + +"minipass@npm:^7.0.4, minipass@npm:^7.1.2": + version: 7.1.3 + resolution: "minipass@npm:7.1.3" + checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb + languageName: node + linkType: hard + +"minizlib@npm:^3.1.0": + version: 3.1.0 + resolution: "minizlib@npm:3.1.0" + dependencies: + minipass: "npm:^7.1.2" + checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec + languageName: node + linkType: hard + +"ms@npm:^2.1.3": + version: 2.1.3 + resolution: "ms@npm:2.1.3" + checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 + languageName: node + linkType: hard + +"nanoid@npm:^3.3.16": + version: 3.3.16 + resolution: "nanoid@npm:3.3.16" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/bbf2dcffe22d2b62d16de2711752070b539c0f644c7916f823ad6521986b2078cbe524f2d6240f58c46e9141ea0c7b87a029e100c0f7f175228cacaf30e41bba + languageName: node + linkType: hard + +"node-gyp@npm:latest": + version: 13.0.1 + resolution: "node-gyp@npm:13.0.1" + dependencies: + env-paths: "npm:^2.2.0" + exponential-backoff: "npm:^3.1.1" + graceful-fs: "npm:^4.2.6" + nopt: "npm:^10.0.0" + proc-log: "npm:^7.0.0" + semver: "npm:^7.3.5" + tar: "npm:^7.5.4" + tinyglobby: "npm:^0.2.12" + undici: "npm:^8.4.1" + which: "npm:^7.0.0" + bin: + node-gyp: bin/node-gyp.js + checksum: 10c0/424077bc9e9bbe953a8e86db473ba818cbc6a121714008c977fd589e21e5f0c811fbf22faac730dc7182450b5e52df301811d01ae3373898658d999b7710f4e6 + languageName: node + linkType: hard + +"node-releases@npm:^2.0.51": + version: 2.0.51 + resolution: "node-releases@npm:2.0.51" + checksum: 10c0/6cf3fe1f9eabe02ce6de67e9db77b73edc9f5625003791e1f8f239f8e2a851450a5aeb1eff2cc43cfb26312e19b26bfe07626bf428479e6a76f56553fc6148da + languageName: node + linkType: hard + +"nopt@npm:^10.0.0": + version: 10.0.1 + resolution: "nopt@npm:10.0.1" + dependencies: + abbrev: "npm:^5.0.0" + bin: + nopt: bin/nopt.js + checksum: 10c0/980d89257f9587f3e1f77877ddbf905d6aa3b738ec33e49a4fa1a059a0dd82eb28063982b150654a7ae9de386f2ead60e56172db7d37cf56de545f7392a2a26a + languageName: node + linkType: hard + +"picocolors@npm:^1.1.1": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 + languageName: node + linkType: hard + +"picomatch@npm:^2.3.1": + version: 2.3.2 + resolution: "picomatch@npm:2.3.2" + checksum: 10c0/a554d1709e59be97d1acb9eaedbbc700a5c03dbd4579807baed95100b00420bc729335440ef15004ae2378984e2487a7c1cebd743cfdb72b6fa9ab69223c0d61 + languageName: node + linkType: hard + +"picomatch@npm:^4.0.2, picomatch@npm:^4.0.4": + version: 4.0.5 + resolution: "picomatch@npm:4.0.5" + checksum: 10c0/947bc6b6e1ff1e6c5aaf95b107a0839d12802f4f7b867663f67d47accba939ca1cb582cf99dfc30438efa1c4648ac5990967e783e8929c36b03e8440704ef1bd + languageName: node + linkType: hard + +"postcss@npm:^8.5.3": + version: 8.5.20 + resolution: "postcss@npm:8.5.20" + dependencies: + nanoid: "npm:^3.3.16" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/9c7a0e32c77ac54c5613f974e6ccc97d4a70fdb3bf242617f3fb4d652e5c62a37c86c589e6e7a2d1e7120f80bc169cdf673482b88bd75020d2a44899fa569c13 + languageName: node + linkType: hard + +"proc-log@npm:^7.0.0": + version: 7.0.0 + resolution: "proc-log@npm:7.0.0" + checksum: 10c0/b89c2d862604f35fec795477b0c7e376feab3ba0d4f4d291c4e959567442697cf451ac557d0623c1cc38af45a78128b983410f397a10c5d3a67f76c33de4754b + languageName: node + linkType: hard + +"react-dom@npm:^19.0.0": + version: 19.2.7 + resolution: "react-dom@npm:19.2.7" + dependencies: + scheduler: "npm:^0.27.0" + peerDependencies: + react: ^19.2.7 + checksum: 10c0/970ff600f6e80d47d39e2f226f12f226173b3cba3382efc97c5f0cd663de9af38c7a4c11c213fb936094faeac83060d660247accaa96b752180d5b951b9cfecb + languageName: node + linkType: hard + +"react-refresh@npm:^0.17.0": + version: 0.17.0 + resolution: "react-refresh@npm:0.17.0" + checksum: 10c0/002cba940384c9930008c0bce26cac97a9d5682bc623112c2268ba0c155127d9c178a9a5cc2212d560088d60dfd503edd808669a25f9b377f316a32361d0b23c + languageName: node + linkType: hard + +"react@npm:^19.0.0": + version: 19.2.7 + resolution: "react@npm:19.2.7" + checksum: 10c0/0bd0e2f1bbd4ba97561c6597bf8a5fec05e6476fe61e165c1065598d16668efc6715205599c94d3ddd49d36cb0f21cbf1b9bcc18ee840b805ce222c3e8d558ac + languageName: node + linkType: hard + +"rollup@npm:^4.34.9": + version: 4.62.2 + resolution: "rollup@npm:4.62.2" + dependencies: + "@rollup/rollup-android-arm-eabi": "npm:4.62.2" + "@rollup/rollup-android-arm64": "npm:4.62.2" + "@rollup/rollup-darwin-arm64": "npm:4.62.2" + "@rollup/rollup-darwin-x64": "npm:4.62.2" + "@rollup/rollup-freebsd-arm64": "npm:4.62.2" + "@rollup/rollup-freebsd-x64": "npm:4.62.2" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.62.2" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.62.2" + "@rollup/rollup-linux-arm64-gnu": "npm:4.62.2" + "@rollup/rollup-linux-arm64-musl": "npm:4.62.2" + "@rollup/rollup-linux-loong64-gnu": "npm:4.62.2" + "@rollup/rollup-linux-loong64-musl": "npm:4.62.2" + "@rollup/rollup-linux-ppc64-gnu": "npm:4.62.2" + "@rollup/rollup-linux-ppc64-musl": "npm:4.62.2" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.62.2" + "@rollup/rollup-linux-riscv64-musl": "npm:4.62.2" + "@rollup/rollup-linux-s390x-gnu": "npm:4.62.2" + "@rollup/rollup-linux-x64-gnu": "npm:4.62.2" + "@rollup/rollup-linux-x64-musl": "npm:4.62.2" + "@rollup/rollup-openbsd-x64": "npm:4.62.2" + "@rollup/rollup-openharmony-arm64": "npm:4.62.2" + "@rollup/rollup-win32-arm64-msvc": "npm:4.62.2" + "@rollup/rollup-win32-ia32-msvc": "npm:4.62.2" + "@rollup/rollup-win32-x64-gnu": "npm:4.62.2" + "@rollup/rollup-win32-x64-msvc": "npm:4.62.2" + "@types/estree": "npm:1.0.9" + fsevents: "npm:~2.3.2" + dependenciesMeta: + "@rollup/rollup-android-arm-eabi": + optional: true + "@rollup/rollup-android-arm64": + optional: true + "@rollup/rollup-darwin-arm64": + optional: true + "@rollup/rollup-darwin-x64": + optional: true + "@rollup/rollup-freebsd-arm64": + optional: true + "@rollup/rollup-freebsd-x64": + optional: true + "@rollup/rollup-linux-arm-gnueabihf": + optional: true + "@rollup/rollup-linux-arm-musleabihf": + optional: true + "@rollup/rollup-linux-arm64-gnu": + optional: true + "@rollup/rollup-linux-arm64-musl": + optional: true + "@rollup/rollup-linux-loong64-gnu": + optional: true + "@rollup/rollup-linux-loong64-musl": + optional: true + "@rollup/rollup-linux-ppc64-gnu": + optional: true + "@rollup/rollup-linux-ppc64-musl": + optional: true + "@rollup/rollup-linux-riscv64-gnu": + optional: true + "@rollup/rollup-linux-riscv64-musl": + optional: true + "@rollup/rollup-linux-s390x-gnu": + optional: true + "@rollup/rollup-linux-x64-gnu": + optional: true + "@rollup/rollup-linux-x64-musl": + optional: true + "@rollup/rollup-openbsd-x64": + optional: true + "@rollup/rollup-openharmony-arm64": + optional: true + "@rollup/rollup-win32-arm64-msvc": + optional: true + "@rollup/rollup-win32-ia32-msvc": + optional: true + "@rollup/rollup-win32-x64-gnu": + optional: true + "@rollup/rollup-win32-x64-msvc": + optional: true + fsevents: + optional: true + bin: + rollup: dist/bin/rollup + checksum: 10c0/83ff5f4a1fea3fa05db2ef56beceee8c33d4a72b818e19c562f1e85c41076fe5b12aadc44048bb73e60b83336df82154b017fa6bf0186f3141643e6f215fbdcb + languageName: node + linkType: hard + +"scheduler@npm:^0.27.0": + version: 0.27.0 + resolution: "scheduler@npm:0.27.0" + checksum: 10c0/4f03048cb05a3c8fddc45813052251eca00688f413a3cee236d984a161da28db28ba71bd11e7a3dd02f7af84ab28d39fb311431d3b3772fed557945beb00c452 + languageName: node + linkType: hard + +"semver@npm:^6.3.1": + version: 6.3.1 + resolution: "semver@npm:6.3.1" + bin: + semver: bin/semver.js + checksum: 10c0/e3d79b609071caa78bcb6ce2ad81c7966a46a7431d9d58b8800cfa9cb6a63699b3899a0e4bcce36167a284578212d9ae6942b6929ba4aa5015c079a67751d42d + languageName: node + linkType: hard + +"semver@npm:^7.3.5": + version: 7.8.5 + resolution: "semver@npm:7.8.5" + bin: + semver: bin/semver.js + checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c + languageName: node + linkType: hard + +"source-map-js@npm:^1.2.1": + version: 1.2.1 + resolution: "source-map-js@npm:1.2.1" + checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf + languageName: node + linkType: hard + +"tar@npm:^7.5.4": + version: 7.5.20 + resolution: "tar@npm:7.5.20" + dependencies: + "@isaacs/fs-minipass": "npm:^4.0.0" + chownr: "npm:^3.0.0" + minipass: "npm:^7.1.2" + minizlib: "npm:^3.1.0" + yallist: "npm:^5.0.0" + checksum: 10c0/4df4335c6d958b76adf1eaced55889dec3ca1c51f450658a074af80694bb0d9c154a8e93fdf4da617372d1575b121295379993961bbe4cd4b0867c0e5689846a + languageName: node + linkType: hard + +"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.13": + version: 0.2.17 + resolution: "tinyglobby@npm:0.2.17" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.4" + checksum: 10c0/7f7bb0f197c88bc4b20c231e0deca4240ca3bf313a88f5a7fee93a872b84966a4d50220947c0455ad07a60b3b360961c5b7fd979222aeb716a9f99b412002e4c + languageName: node + linkType: hard + +"to-regex-range@npm:^5.0.1": + version: 5.0.1 + resolution: "to-regex-range@npm:5.0.1" + dependencies: + is-number: "npm:^7.0.0" + checksum: 10c0/487988b0a19c654ff3e1961b87f471702e708fa8a8dd02a298ef16da7206692e8552a0250e8b3e8759270f62e9d8314616f6da274734d3b558b1fc7b7724e892 + languageName: node + linkType: hard + +"typescript@npm:^5.7.0": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A^5.7.0#optional!builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/ad09fdf7a756814dce65bc60c1657b40d44451346858eea230e10f2e95a289d9183b6e32e5c11e95acc0ccc214b4f36289dcad4bf1886b0adb84d711d336a430 + languageName: node + linkType: hard + +"undici@npm:^8.4.1": + version: 8.7.0 + resolution: "undici@npm:8.7.0" + checksum: 10c0/b7e5ecb4de82fa4f905011a77544fe7ba4da06f27167ff99313a7ae2869f3cb233d676dcd085533bc69f36989bc40871f5ae6ea6e4c9b91db92616b9e91b579d + languageName: node + linkType: hard + +"update-browserslist-db@npm:^1.2.3": + version: 1.2.3 + resolution: "update-browserslist-db@npm:1.2.3" + dependencies: + escalade: "npm:^3.2.0" + picocolors: "npm:^1.1.1" + peerDependencies: + browserslist: ">= 4.21.0" + bin: + update-browserslist-db: cli.js + checksum: 10c0/13a00355ea822388f68af57410ce3255941d5fb9b7c49342c4709a07c9f230bbef7f7499ae0ca7e0de532e79a82cc0c4edbd125f1a323a1845bf914efddf8bec + languageName: node + linkType: hard + +"vite-plugin-singlefile@npm:^2.1.0": + version: 2.3.3 + resolution: "vite-plugin-singlefile@npm:2.3.3" + dependencies: + micromatch: "npm:^4.0.8" + peerDependencies: + rollup: ^4.59.0 + vite: ^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + rollup: + optional: true + checksum: 10c0/9c2097e7843684706af1c4d18c8f77193c3089494e6fc15c508a7d079f8d85aabb21b8b25bf2af2088fec50a18270aa928a9ce3013adb270673bc8896d51dd80 + languageName: node + linkType: hard + +"vite@npm:^6.0.0": + version: 6.4.3 + resolution: "vite@npm:6.4.3" + dependencies: + esbuild: "npm:^0.25.0" + fdir: "npm:^6.4.4" + fsevents: "npm:~2.3.3" + picomatch: "npm:^4.0.2" + postcss: "npm:^8.5.3" + rollup: "npm:^4.34.9" + tinyglobby: "npm:^0.2.13" + peerDependencies: + "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: ">=1.21.0" + less: "*" + lightningcss: ^1.21.0 + sass: "*" + sass-embedded: "*" + stylus: "*" + sugarss: "*" + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/23ce22e50d5c7a87321f04b155c5bc3cf915e34e6c40918975741ed5e8b0c257039a643f1bc1cd829ee814ad92a138704e82084b7ebe40b399a62d8effea630c + languageName: node + linkType: hard + +"which@npm:^7.0.0": + version: 7.0.0 + resolution: "which@npm:7.0.0" + dependencies: + isexe: "npm:^4.0.0" + bin: + node-which: bin/which.js + checksum: 10c0/ca0b54f198f78bbc4b7c02e34bda8d335cb352e0adb4cbca1c37b1a957af3a879a82c4c27ca6525bc942f548d8b64f816ef6528360af9f3de55ffb9b979b620d + languageName: node + linkType: hard + +"yallist@npm:^3.0.2": + version: 3.1.1 + resolution: "yallist@npm:3.1.1" + checksum: 10c0/c66a5c46bc89af1625476f7f0f2ec3653c1a1791d2f9407cfb4c2ba812a1e1c9941416d71ba9719876530e3340a99925f697142989371b72d93b9ee628afd8c1 + languageName: node + linkType: hard + +"yallist@npm:^5.0.0": + version: 5.0.0 + resolution: "yallist@npm:5.0.0" + checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416 + languageName: node + linkType: hard