From 131119d5f68dd17984079bb4c63263f232b4c553 Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Sun, 24 May 2026 08:47:57 -0600 Subject: [PATCH 1/7] chore: project tooling, CI, lint/format, and test setup Establish the build and contribution scaffolding ahead of the implementation: - package.json/bun.lock: runtime and dev dependencies - tsconfig.json: strict TS config with #src path alias - eslint.config.mjs, .prettierignore: lint/format config - bunfig.toml, vitest.config.ts, vitest.setup.ts: test runner config - .github workflows + bootstrap action: PR/main/test CI - .husky pre-commit/pre-push hooks - .claude/rules: team conventions (neverthrow, Context/fakes, bun-native APIs, testing patterns) - scripts/ensure-test-template.ts: test scaffolding helper --- .claude/rules/bun-native-apis.md | 93 ++ .claude/rules/bun-testing.md | 47 + .claude/rules/context-interfaces-and-fakes.md | 14 + .claude/rules/error-handling-neverthrow.md | 34 + .claude/rules/testing-patterns.md | 35 + .github/actions/bootstrap/action.yml | 52 ++ .github/workflows/main.yml | 16 + .github/workflows/pr.yml | 15 + .github/workflows/test.yml | 54 ++ .gitignore | 3 + .husky/pre-commit | 1 + .husky/pre-push | 9 + .prettierignore | 6 + bun.lock | 869 +++++++++++++++++- bunfig.toml | 2 + eslint.config.mjs | 66 ++ package.json | 82 +- tsconfig.json | 6 +- vitest.config.ts | 37 + vitest.setup.ts | 1 + 20 files changed, 1431 insertions(+), 11 deletions(-) create mode 100644 .claude/rules/bun-native-apis.md create mode 100644 .claude/rules/bun-testing.md create mode 100644 .claude/rules/context-interfaces-and-fakes.md create mode 100644 .claude/rules/error-handling-neverthrow.md create mode 100644 .claude/rules/testing-patterns.md create mode 100644 .github/actions/bootstrap/action.yml create mode 100644 .github/workflows/main.yml create mode 100644 .github/workflows/pr.yml create mode 100644 .github/workflows/test.yml create mode 100755 .husky/pre-commit create mode 100755 .husky/pre-push create mode 100644 .prettierignore create mode 100644 bunfig.toml create mode 100644 eslint.config.mjs create mode 100644 vitest.config.ts create mode 100644 vitest.setup.ts diff --git a/.claude/rules/bun-native-apis.md b/.claude/rules/bun-native-apis.md new file mode 100644 index 0000000..39def66 --- /dev/null +++ b/.claude/rules/bun-native-apis.md @@ -0,0 +1,93 @@ +--- +paths: ['src/**/*.ts'] +globs: ['src/**/*.ts'] +--- + +# Bun-native APIs + +This project runs on Bun. Reach for `Bun.*` globals before the Node equivalent. Training-data bias makes the Node API the default suggestion; this rule lists the cases where the Bun shape is materially shorter or more idiomatic and should win. + +For anything not covered below, the canonical reference is — it concatenates Bun's full docs into a single markdown file. + +## File system reads + +`Bun.file(path).text() / .json() / .bytes()` instead of `readFileSync` + `JSON.parse`. Reads are lazy: no I/O until the accessor is awaited. + +```ts +// Good +const content = await Bun.file(argPath).text(); +const config = await Bun.file(configPath).json(); + +// Avoid +const content = readFileSync(argPath, 'utf8'); +const config = JSON.parse(readFileSync(configPath, 'utf8')) as Config; +``` + +## File system writes + +`Bun.write(path, data)` instead of `writeFileSync`. Auto-creates parent directories, accepts strings, `Bun.file` handles, `ArrayBuffer`, or `Response`. + +```ts +// Good +await Bun.write(outPath, rendered); + +// Avoid +mkdirSync(dirname(outPath), { recursive: true }); +writeFileSync(outPath, rendered, 'utf8'); +``` + +## Globbing & directory walks + +`new Bun.Glob(pattern).scanSync(root)` (or async `scan`) instead of `readdirSync({ recursive: true })` and instead of any hand-rolled recursive `walk()`. The recursive `readdirSync` flag works but doesn't pattern-filter; rolling a walk yourself is the most common Node carry-over. + +```ts +// Good +const files = Array.from(new Bun.Glob('**/*.md').scanSync(rootDir)).sort(); + +// Avoid +function walk(dir: string): string[] { + /* recursive readdirSync... */ +} +const files = walk(rootDir).filter((p) => p.endsWith('.md')); +``` + +## Subprocesses + +`Bun.spawn` / `Bun.spawnSync` instead of `child_process.spawn` / `exec`. Use `Bun.which` instead of probing `$PATH` manually. + +```ts +// Good +const proc = Bun.spawn({ cmd: [bin, ...args], stdout: 'pipe', stderr: 'pipe' }); +const stdout = await new Response(proc.stdout).text(); +const exitCode = await proc.exited; + +// Avoid +const { stdout } = await promisify(execFile)(bin, args); +``` + +## HTTP servers + +`Bun.serve` instead of Express, Fastify, or `node:http`. + +## SQLite + +`bun:sqlite` instead of `better-sqlite3` or `node:sqlite`. + +## Compile-time evaluation + +Two related Bun-only import attribute forms; reach for them whenever the value is fixed at bundle time. + +- `with { type: 'text' }` to embed a file's contents as a string literal at build. +- `with { type: 'macro' }` to evaluate a function at build time and inline its return value. Right shape for version stamps, fixture generation, build-time enumeration of files, and anything else that's a pure function of the source tree. + +If you catch yourself writing a `readFileSync(...)` or a constant lookup at the top of a module to compute something the bundler could compute once, it's a macro. + +## What not to use + +- `fs.readdirSync(..., { recursive: true })` walks → use `Bun.Glob`. +- Hand-rolled recursive `walk()` helpers → use `Bun.Glob`. +- `child_process.exec` / `execFile` → use `Bun.spawn`. +- `node:http` for new servers → use `Bun.serve`. +- `better-sqlite3` / `node:sqlite` → use `bun:sqlite`. + +`readFileSync` and `writeFileSync` are not banned outright — they're fine in a tight synchronous loop where `await` would force a redundant restructure — but `Bun.file` / `Bun.write` should be the default. diff --git a/.claude/rules/bun-testing.md b/.claude/rules/bun-testing.md new file mode 100644 index 0000000..4d957f0 --- /dev/null +++ b/.claude/rules/bun-testing.md @@ -0,0 +1,47 @@ +--- +paths: ['src/**/*.test.ts'] +globs: ['src/**/*.test.ts'] +--- + +# Bun testing conventions + +Conventions specific to `bun:test` that are non-obvious and have bitten us. + +## `.rejects` / `.resolves` matchers are synchronous — don't `await`, don't `async`, don't `nextTick` + +`expect(promise).rejects.toX()` and `expect(promise).resolves.toX()` **block synchronously at runtime** via `globalThis.bunVM().waitForPromise(...)`. `bun-types` types them as returning `void` — the types are wrong and [upstream closed the fix as "not planned"](https://github.com/oven-sh/bun/issues/15457). + +**Correct pattern:** + +```ts +it('rejects on X', () => { + expect(fn(...)).rejects.toBeInstanceOf(SomeError); + expect(io.stderr.text()).toContain('expected message'); +}); +``` + +No `async`, no `await`, no manual `nextTick` flush. + +**Why:** adapting Jest habits to Bun produces an antagonistic lint/TS pair: + +- With `await expect(...).rejects.toX()` → TS80007 "`await` has no effect on the type of this expression" (the matcher is typed `void`). +- Without `await` → `@typescript-eslint/require-await` flags `async` as pointless. + +Dropping both resolves both. The matcher still works — it already blocks the thread. + +**`nextTick` is also unnecessary.** Some older tests have: + +```ts +// CARGO-CULT — DO NOT COPY +it('rejects on X', async () => { + expect(fn(...)).rejects.toBeInstanceOf(SomeError); + await nextTick(); + expect(readErrorLogs(logs).some(...)).toBe(true); +}); +``` + +That helper is a leftover from Jest patterns. `waitForPromise` already settled the promise, and stream-backed reads are ready immediately on the next line. + +**Keep `async` only when the body has a real `await`** — e.g. `await runHandler(ctx)` on a happy path, or async fixture setup. Don't keep it "just in case." + +**Don't try to patch the types.** `Matchers.resolves` / `Matchers.rejects` are typed as property accessors on an interface; TS declaration merging can only add, not override. A local wrapper helper would work, but Bun's synchronous behavior makes it unnecessary. diff --git a/.claude/rules/context-interfaces-and-fakes.md b/.claude/rules/context-interfaces-and-fakes.md new file mode 100644 index 0000000..5f79be4 --- /dev/null +++ b/.claude/rules/context-interfaces-and-fakes.md @@ -0,0 +1,14 @@ +--- +paths: ['src/**/*.ts'] +globs: ['src/**/*.ts'] +--- + +# Context Interfaces and Fakes + +- **Injectable dependencies should have a public contract and separate implementations.** When adding a dependency that performs I/O, external API calls, filesystem access, or other side effects, define a narrow public interface for the behavior callers depend on. Callers accept the interface; production code wires up the real implementation. + +- **Name the production implementation `XxxImpl`.** The real implementation implements the public interface and carries the environment-specific behavior. Consumer parameters and struct fields should be typed as the interface, not the concrete implementation. + +- **Name the test implementation `FakeXxx`.** Shared test helpers implement the same public interface with deterministic, inspectable behavior. Keep fakes focused on the interface contract so tests can swap them in without casts, module mocking, or duplicating production implementation details. + +- **Pass dependencies as plain function arguments or a small `Context` object.** Don't reach for module-level mutable singletons or globals; they make tests order-dependent and force module mocking. Collectors and other long-running operations should accept their client/IO dependencies as arguments. diff --git a/.claude/rules/error-handling-neverthrow.md b/.claude/rules/error-handling-neverthrow.md new file mode 100644 index 0000000..8a5480d --- /dev/null +++ b/.claude/rules/error-handling-neverthrow.md @@ -0,0 +1,34 @@ +--- +paths: ['src/**/*.ts', 'src/**/*.tsx'] +globs: ['src/**/*.ts', 'src/**/*.tsx'] +--- + +# Error Handling Neverthrow + +- **Use neverthrow for safe wrappers around fallible I/O.** Wrap operations that can throw (file reads, JSON parsing, API calls) in `Result` types via `fromThrowable` for sync work and `ResultAsync.fromPromise` for promises. This keeps error handling explicit and chainable without try/catch blocks scattered through business logic. + + **Good:** + + ```typescript + import { fromThrowable, Result, ResultAsync } from 'neverthrow'; + import { toError } from '#src/errors.ts'; + + const safeRead = fromThrowable((path: string) => readFileSync(path, 'utf8').trim()); + const existing = safeRead(path).unwrapOr(''); + + export const safeJsonParse = Result.fromThrowable((text: string) => JSON.parse(text) as unknown, toError); + + function fetchUser(id: string): ResultAsync { + return ResultAsync.fromPromise(client.users.get(id), toFetchError); + } + ``` + +- **Use discriminated-union errors at module boundaries.** Each module that produces errors defines a small `XxxError` union with a `kind` discriminator. Downstream callers narrow by `kind` instead of `instanceof`. Keep the union flat — don't nest causes inside other unions. + +- **Collectors and other long-running operations chain with `.andThen` / `.map` / `.mapErr`.** They never `throw`, never wrap a bare `await` over a fallible Promise, and never `try/catch`. If a sub-call returns `ResultAsync`, chain it; don't unwrap mid-flight. + +- **The CLI entrypoint is the only place that unwraps.** It pattern-matches on the error type to format a human message, then sets a non-zero exit code. Do not use `unwrapOr` inside business logic when the fallback would silently hide a real bug — only at presentation boundaries where partial data is the intentional behavior. + +- **Partial-failure boundaries are explicit.** When a per-item operation may fail and the run should continue (e.g. per-repo crawl where one repo's 403 shouldn't kill the report), the boundary that accepts partial failure handles the `Err` explicitly: log it, push a warning into the slice, and proceed with the successful items. Inside each per-item collector, no try/catch. + +- **Tests assert on `Result` shapes.** Use `.isOk()`, `.isErr()`, `.unwrapOr(default)`, and value/error inspection — not try/catch. A test that catches a thrown error is a sign the production code should be returning a `Result`. diff --git a/.claude/rules/testing-patterns.md b/.claude/rules/testing-patterns.md new file mode 100644 index 0000000..3311614 --- /dev/null +++ b/.claude/rules/testing-patterns.md @@ -0,0 +1,35 @@ +--- +paths: ['src/**/*.test.ts'] +globs: ['src/**/*.test.ts'] +--- + +# Testing Patterns + +- **Test factories use Fishery with `.build()` invocations.** Test data is constructed via Fishery `Factory.define()` factories, never hand-rolled `createXxx()` helpers with inline object literals. Factories live next to the type they produce (e.g. `src//testFactories.ts`). Tests call `.build({ overrides })` to get fixture data. + + **Good:** + + ```typescript + import { Factory } from 'fishery'; + import type { RepoMeta } from '#src/types.ts'; + + export const repoMeta = Factory.define(() => ({ + owner: 'acme', + name: 'widgets', + visibility: 'private', + archived: false, + defaultBranch: 'main', + primaryLanguage: 'TypeScript', + pushedAt: '2026-01-01T00:00:00Z', + dependabotSecurityUpdates: true, + })); + + // In test: + const repo = repoMeta.build({ archived: true }); + ``` + +- **Prefer `toMatchObject` for structured payload assertions.** When a test verifies several fields on the same object or nested payload, use one `expect(value).toMatchObject({ ... })` instead of a run of field-by-field assertions. Keep separate assertions for orthogonal behavior, clearer failure messages, or values that need a specialized matcher. + +- **Use a shared deferred-promise helper; do not hand-roll it.** Tests that need manual promise resolution should import a single `createDeferred` helper. Do not recreate local `Deferred` types or `new Promise` wrappers in individual test files. + +- **Assert on `Result` shapes, not on thrown errors.** Use `.isOk()`, `.isErr()`, `.unwrapOr(default)`, and value/error inspection — not try/catch. A test that catches a thrown error is a sign the production code should be returning a `Result` (see `error-handling-neverthrow.md`). diff --git a/.github/actions/bootstrap/action.yml b/.github/actions/bootstrap/action.yml new file mode 100644 index 0000000..21c24fd --- /dev/null +++ b/.github/actions/bootstrap/action.yml @@ -0,0 +1,52 @@ +name: Bootstrap +description: Install Bun via asdf and project dependencies + +runs: + using: 'composite' + steps: + - name: Setup asdf + uses: asdf-vm/actions/setup@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.1 + with: + asdf_version: '0.18.0' + + - name: Restore asdf cache + id: asdf-cache-restore + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + key: asdf-tools-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.tool-versions') }} + path: | + ~/.asdf/installs + ~/.asdf/plugins + ~/.asdf/shims + restore-keys: asdf-tools-v1-${{ runner.os }}-${{ runner.arch }}- + + - name: Install asdf plugins + if: ${{ steps.asdf-cache-restore.outputs.cache-hit != 'true' }} + uses: asdf-vm/actions/install@b7bcd026f18772e44fe1026d729e1611cc435d47 # v4.0.1 + with: + asdf_version: '0.18.0' + + - name: Reshim asdf + shell: bash + run: asdf reshim + + - name: Save asdf cache + if: always() && steps.asdf-cache-restore.outputs.cache-hit != 'true' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + key: ${{ steps.asdf-cache-restore.outputs.cache-primary-key }} + path: | + ~/.asdf/installs + ~/.asdf/plugins + ~/.asdf/shims + + - name: Restore Bun install cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + key: bun-install-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('bun.lock') }} + path: ~/.bun/install/cache + restore-keys: bun-install-${{ runner.os }}-${{ runner.arch }}- + + - name: Install dependencies + shell: bash + run: bun install --frozen-lockfile diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..bb5e365 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,16 @@ +name: Main Branch + +on: + push: + branches: [main] + +concurrency: + group: main + cancel-in-progress: false + +jobs: + test: + name: Tests + uses: ./.github/workflows/test.yml + permissions: + contents: read diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..7916dc3 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,15 @@ +name: PR + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Tests + uses: ./.github/workflows/test.yml + permissions: + contents: read diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fbcd39c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,54 @@ +name: Test + +on: + workflow_call: + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: ./.github/actions/bootstrap + - name: Check formatting + run: bun run format:check + - name: Restore ESLint cache + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: .eslintcache + key: eslint-${{ runner.os }}-${{ github.sha }} + restore-keys: eslint-${{ runner.os }}- + - name: Run ESLint + run: bun run lint + - name: Typecheck + run: bun run typecheck + + test: + name: Test + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: ./.github/actions/bootstrap + - name: Get Playwright version + id: playwright-version + run: echo "version=$(jq -r '.devDependencies.playwright' package.json)" >> "$GITHUB_OUTPUT" + - name: Get Ubuntu version + id: ubuntu-version + run: echo "version=$(lsb_release -rs)" >> "$GITHUB_OUTPUT" + - name: Cache Playwright browsers + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: /home/runner/.cache/ms-playwright + key: ${{ runner.os }}-${{ steps.ubuntu-version.outputs.version }}-playwright-browsers-${{ steps.playwright-version.outputs.version }} + restore-keys: | + ${{ runner.os }}-${{ steps.ubuntu-version.outputs.version }}-playwright-browsers- + - name: Install Playwright browsers + run: bunx playwright install --with-deps chromium + - name: Run tests + run: bun run test diff --git a/.gitignore b/.gitignore index a14702c..e2eec53 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ out dist *.tgz +# dev-only fixture for the report web app +src/report/web/data/fixture.json + # code coverage coverage *.lcov diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..ea5a55b --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +bunx lint-staged diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..d6df897 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,9 @@ +bun run lint +bun run typecheck + +# AI agent only — set IS_AI_AGENT=1 to enable +if [ "$IS_AI_AGENT" = "1" ]; then + bun test +else + echo "tests skipped (set IS_AI_AGENT=1 to run on pre-push)" +fi diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..1000aef --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +dist/ +node_modules/ +coverage/ +claude-tmp/ +bun.lock +.husky/_/ diff --git a/bun.lock b/bun.lock index 0829e9f..a59e995 100644 --- a/bun.lock +++ b/bun.lock @@ -4,23 +4,884 @@ "workspaces": { "": { "name": "patchwave-analysis", + "dependencies": { + "@js-temporal/polyfill": "^0.5.1", + "@octokit/graphql": "^9.0.3", + "@octokit/plugin-retry": "^8.1.0", + "@octokit/plugin-throttling": "^11.0.3", + "@octokit/rest": "^22.0.1", + "fflate": "^0.8.3", + "neverthrow": "^8.2.0", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", + "posthog-node": "^5.35.1", + "react": "^19", + "react-dom": "^19", + "semver": "^7.8.1", + "zod": "^4.4.3", + }, "devDependencies": { + "@contextbridge-ai/eslint-config": "^0.0.0", + "@contextbridge-ai/prettier-config": "^0.0.0", + "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/ibm-plex-sans": "^5.2.8", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/bun": "latest", - }, - "peerDependencies": { - "typescript": "^5", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/semver": "^7.7.1", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/browser-playwright": "^4.0.18", + "bun-plugin-tailwind": "^0.1.2", + "eslint": "^10.4.0", + "fishery": "^2.4.0", + "globals": "^17.6.0", + "husky": "^9.1.7", + "lint-staged": "^17.0.5", + "playwright": "^1.58.2", + "prettier": "^3.8.3", + "tailwindcss": "^4", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.4", + "vite": "^7.2.4", + "vitest": "^4.0.18", }, }, }, "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + + "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="], + + "@contextbridge-ai/eslint-config": ["@contextbridge-ai/eslint-config@0.0.0", "", { "dependencies": { "@eslint/js": "^10.0.1", "eslint-plugin-import-x": "^4.16.2", "eslint-plugin-unused-imports": "^4.4.1", "typescript-eslint": "^8.59.1" }, "peerDependencies": { "eslint": "^10.0.0", "typescript": "^6.0.0" } }, "sha512-5+1HATOHYQNN9xR4wYiRUZ0Qb+km9YdMyr94sWbkVGApCc9zb7BQDsVHSEFjtkHq28NQ9DIRgPeuwCjxFjkngg=="], + + "@contextbridge-ai/prettier-config": ["@contextbridge-ai/prettier-config@0.0.0", "", { "peerDependencies": { "prettier": "^3.0.0" } }, "sha512-hW6TZzP3PT9Mcj84xdfduwEWW+j/K6rzc5dZqdx9lOzfz7HGRWI26rWG9snNkv49REyGkLW2QvSA5C60utJiiw=="], + + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], + + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], + + "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], + + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], + + "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.2.7", "", {}, "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w=="], + + "@fontsource/ibm-plex-sans": ["@fontsource/ibm-plex-sans@5.2.8", "", {}, "sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ=="], + + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], + + "@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="], + + "@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + + "@octokit/graphql": ["@octokit/graphql@9.0.3", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], + + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], + + "@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="], + + "@octokit/plugin-retry": ["@octokit/plugin-retry@8.1.0", "", { "dependencies": { "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": ">=7" } }, "sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw=="], + + "@octokit/plugin-throttling": ["@octokit/plugin-throttling@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": "^7.0.0" } }, "sha512-34eE0RkFCKycLl2D2kq7W+LovheM/ex3AwZCYN8udpi6bxsyjZidb2McXs69hZhLmJlDqTSP8cH+jSRpiaijBg=="], + + "@octokit/request": ["@octokit/request@10.0.9", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "fast-content-type-parse": "^3.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-o8Bi3f608eyM+7BmBiUWxFsdjLb3/ym1cQek5LZOv9KkZcxRrHCPhhRzm6xjO6HVZ85ItD6+sTsjxo821SVa/A=="], + + "@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + + "@octokit/rest": ["@octokit/rest@22.0.1", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="], + + "@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="], + + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="], + + "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g=="], + + "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.3.14", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw=="], + + "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.3.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw=="], + + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw=="], + + "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.3.14", "", { "os": "android", "cpu": "arm64" }, "sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA=="], + + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA=="], + + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw=="], + + "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.3.14", "", { "os": "android", "cpu": "x64" }, "sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg=="], + + "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA=="], + + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg=="], + + "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw=="], + + "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA=="], + + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg=="], + + "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="], + + "@package-json/types": ["@package-json/types@0.0.12", "", {}, "sha512-uu43FGU34B5VM9mCNjXCwLaGHYjXdNincqKLaraaCW+7S2+SmiBg1Nv8bPnmschrIfZmfKNY9f3fC376MRrObw=="], + + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@posthog/core": ["@posthog/core@1.29.9", "", { "dependencies": { "@posthog/types": "1.376.0" } }, "sha512-DjvuIyBZ2Z/gBhtZlITlM2D8PlnMsHSQ1D78dbUYoVsgGguvanpJTobZObjLlFkybyvfZFYkpoJkFNI/2Pw4IQ=="], + + "@posthog/types": ["@posthog/types@1.376.0", "", {}, "sha512-gbFfxCuZDs/D4QZMwdE+smD1jsuqgGpS6yKGHZZ19foxMy8RYHsU1E47iG1b88n/uN02fAabLibVwuxLtq8juw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.4", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/type-utils": "8.59.4", "@typescript-eslint/utils": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.4", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.4", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.4", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.4", "@typescript-eslint/types": "^8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4" } }, "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.4", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.59.4", "", {}, "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.4", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.4", "@typescript-eslint/tsconfig-utils": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "eslint-visitor-keys": "^5.0.0" } }, "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ=="], + + "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.12.2", "", { "os": "android", "cpu": "arm" }, "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w=="], + + "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.12.2", "", { "os": "android", "cpu": "arm64" }, "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ=="], + + "@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.12.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w=="], + + "@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.12.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA=="], + + "@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.12.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg=="], + + "@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A=="], + + "@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.12.2", "", { "os": "linux", "cpu": "arm" }, "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g=="], + + "@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.12.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg=="], + + "@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.12.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA=="], + + "@unrs/resolver-binding-linux-loong64-gnu": ["@unrs/resolver-binding-linux-loong64-gnu@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q=="], + + "@unrs/resolver-binding-linux-loong64-musl": ["@unrs/resolver-binding-linux-loong64-musl@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew=="], + + "@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.12.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg=="], + + "@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A=="], + + "@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w=="], + + "@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.12.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw=="], + + "@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.12.2", "", { "os": "linux", "cpu": "x64" }, "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ=="], + + "@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.12.2", "", { "os": "linux", "cpu": "x64" }, "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A=="], + + "@unrs/resolver-binding-openharmony-arm64": ["@unrs/resolver-binding-openharmony-arm64@1.12.2", "", { "os": "none", "cpu": "arm64" }, "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ=="], + + "@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.12.2", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A=="], + + "@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.12.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g=="], + + "@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.12.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g=="], + + "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.12.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], + + "@vitest/browser": ["@vitest/browser@4.1.7", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.7", "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.7" } }, "sha512-N2JFGfXoEGVAut+kHeru9dD4BUMq/q5xDvBARNl0tUsly3m5KglLOu8VO/6MkDfOlgxXTycojkt6gBKsuyR+IQ=="], + + "@vitest/browser-playwright": ["@vitest/browser-playwright@4.1.7", "", { "dependencies": { "@vitest/browser": "4.1.7", "@vitest/mocker": "4.1.7", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "playwright": "*", "vitest": "4.1.7" } }, "sha512-OlTlJej7YN6VwV7zJJoNeaCsctF+JXpzpZ4oBHUbrQFfIq+0KW2f07rprCLh9N/zRIZ0v4Mchn1QDDmWMUhPKw=="], + + "@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.7", "", { "dependencies": { "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.7", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw=="], + + "@vitest/runner": ["@vitest/runner@4.1.7", "", { "dependencies": { "@vitest/utils": "4.1.7", "pathe": "^2.0.3" } }, "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.7", "", { "dependencies": { "@vitest/pretty-format": "4.1.7", "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw=="], + + "@vitest/spy": ["@vitest/spy@4.1.7", "", {}, "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q=="], + + "@vitest/utils": ["@vitest/utils@4.1.7", "", { "dependencies": { "@vitest/pretty-format": "4.1.7", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.32", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg=="], + + "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + + "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], + + "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "bun": ["bun@1.3.14", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.14", "@oven/bun-darwin-x64": "1.3.14", "@oven/bun-darwin-x64-baseline": "1.3.14", "@oven/bun-freebsd-aarch64": "1.3.14", "@oven/bun-freebsd-x64": "1.3.14", "@oven/bun-linux-aarch64": "1.3.14", "@oven/bun-linux-aarch64-android": "1.3.14", "@oven/bun-linux-aarch64-musl": "1.3.14", "@oven/bun-linux-x64": "1.3.14", "@oven/bun-linux-x64-android": "1.3.14", "@oven/bun-linux-x64-baseline": "1.3.14", "@oven/bun-linux-x64-musl": "1.3.14", "@oven/bun-linux-x64-musl-baseline": "1.3.14", "@oven/bun-windows-aarch64": "1.3.14", "@oven/bun-windows-x64": "1.3.14", "@oven/bun-windows-x64-baseline": "1.3.14" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="], + + "bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], + + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + + "comment-parser": ["comment-parser@1.4.6", "", {}, "sha512-ObxuY6vnbWTN6Od72xfwN9DbzC7Y2vv8u1Soi9ahRKL37gb6y1qk6/dgjs+3JWuXJHWvsg3BXIwzd/rkmAwavg=="], + + "content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.361", "", {}, "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + + "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], + + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@10.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="], + + "eslint-import-context": ["eslint-import-context@0.1.9", "", { "dependencies": { "get-tsconfig": "^4.10.1", "stable-hash-x": "^0.2.0" }, "peerDependencies": { "unrs-resolver": "^1.0.0" }, "optionalPeers": ["unrs-resolver"] }, "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg=="], + + "eslint-plugin-import-x": ["eslint-plugin-import-x@4.16.2", "", { "dependencies": { "@package-json/types": "^0.0.12", "@typescript-eslint/types": "^8.56.0", "comment-parser": "^1.4.1", "debug": "^4.4.1", "eslint-import-context": "^0.1.9", "is-glob": "^4.0.3", "minimatch": "^9.0.3 || ^10.1.2", "semver": "^7.7.2", "stable-hash-x": "^0.2.0", "unrs-resolver": "^1.9.2" }, "peerDependencies": { "@typescript-eslint/utils": "^8.56.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "eslint-import-resolver-node": "*" }, "optionalPeers": ["@typescript-eslint/utils", "eslint-import-resolver-node"] }, "sha512-rM9K8UBHcWKpzQzStn1YRN2T5NvdeIfSVoKu/lKF41znQXHAUcBbYXe5wd6GNjZjTrP7viQ49n1D83x/2gYgIw=="], + + "eslint-plugin-unused-imports": ["eslint-plugin-unused-imports@4.4.1", "", { "peerDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" }, "optionalPeers": ["@typescript-eslint/eslint-plugin"] }, "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ=="], + + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], + + "fast-copy": ["fast-copy@4.0.3", "", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "fishery": ["fishery@2.4.0", "", { "dependencies": { "lodash.mergewith": "^4.6.2" } }, "sha512-QgeTlvgNhVGuMztrfAhlSIBs3rD3l9RMjl9I15yb/lnrx3njrOhvegr2L3LWdqvXwYfQjdQGpglyAfHH2J8DRA=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="], + + "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], + + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json-with-bigint": ["json-with-bigint@3.5.8", "", {}, "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lint-staged": ["lint-staged@17.0.5", "", { "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", "tinyexec": "^1.1.2" }, "optionalDependencies": { "yaml": "^2.8.4" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-d12yC+/e8RhBjZtaxZn71FyrgU/P5e+uAPifhCLwdosQZP/zamSdKRWDC30ocVIbzDKiFG1McHc/LUgB92GIPw=="], + + "listr2": ["listr2@10.2.1", "", { "dependencies": { "cli-truncate": "^5.2.0", "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^10.0.0" } }, "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.mergewith": ["lodash.mergewith@4.6.2", "", {}, "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ=="], + + "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "neverthrow": ["neverthrow@8.2.0", "", { "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "^4.24.0" } }, "sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ=="], + + "node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "pino": ["pino@10.3.1", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg=="], + + "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="], + + "pino-pretty": ["pino-pretty@13.1.3", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^4.0.0", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pump": "^3.0.0", "secure-json-parse": "^4.0.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^5.0.2" }, "bin": { "pino-pretty": "bin.js" } }, "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg=="], + + "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + + "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], + + "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], + + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "posthog-node": ["posthog-node@5.35.1", "", { "dependencies": { "@posthog/core": "1.29.9" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-F9S3pEIYfGEVjLYIFHKaqfTIhn5IpS02Dkp7C/f1rqr4Z67Iqbt4jbKO8raWsT0veEI3rUp+DKuXLW1hN07FQA=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], + + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], + + "semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], + + "sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "stable-hash-x": ["stable-hash-x@0.2.0", "", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], + + "string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + + "tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="], + + "thread-stream": ["thread-stream@4.2.0", "", { "dependencies": { "real-require": "^1.0.0" } }, "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "typescript-eslint": ["typescript-eslint@8.59.4", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.4", "@typescript-eslint/parser": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + + "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + + "unrs-resolver": ["unrs-resolver@1.12.2", "", { "dependencies": { "napi-postinstall": "^0.3.4" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.12.2", "@unrs/resolver-binding-android-arm64": "1.12.2", "@unrs/resolver-binding-darwin-arm64": "1.12.2", "@unrs/resolver-binding-darwin-x64": "1.12.2", "@unrs/resolver-binding-freebsd-x64": "1.12.2", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", "@unrs/resolver-binding-linux-x64-musl": "1.12.2", "@unrs/resolver-binding-openharmony-arm64": "1.12.2", "@unrs/resolver-binding-wasm32-wasi": "1.12.2", "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="], + + "vitest": ["vitest@4.1.7", "", { "dependencies": { "@vitest/expect": "4.1.7", "@vitest/mocker": "4.1.7", "@vitest/pretty-format": "4.1.7", "@vitest/runner": "4.1.7", "@vitest/snapshot": "4.1.7", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.7", "@vitest/browser-preview": "4.1.7", "@vitest/browser-webdriverio": "4.1.7", "@vitest/coverage-istanbul": "4.1.7", "@vitest/coverage-v8": "4.1.7", "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + + "log-update/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], + + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "log-update/slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "log-update/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "log-update/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], } } diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..de249f9 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[serve.static] +plugins = ["bun-plugin-tailwind"] diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..9497ad6 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,66 @@ +import baseConfig from '@contextbridge-ai/eslint-config/base'; +import { defineConfig } from 'eslint/config'; + +// Flat-config array-valued rules (like no-restricted-syntax) are REPLACED, not +// merged, when a later matching block sets the same rule. Keep the shared +// selectors here so scoped blocks can include them alongside their own. +const dateRestrictedSelectors = [ + { + selector: 'NewExpression[callee.name="Date"]', + message: 'Use Temporal from ./src/time.ts instead of Date.', + }, + { + selector: 'CallExpression[callee.name="Date"]', + message: 'Use Temporal from ./src/time.ts instead of Date.', + }, + { + selector: 'CallExpression[callee.object.name="Date"][callee.property.name="now"]', + message: 'Use Temporal from ./src/time.ts instead of Date.now().', + }, + { + selector: 'CallExpression[callee.object.name="Date"][callee.property.name="parse"]', + message: 'Use Temporal from ./src/time.ts instead of Date.parse().', + }, + { + selector: 'CallExpression[callee.object.name="Date"][callee.property.name="UTC"]', + message: 'Use Temporal from ./src/time.ts instead of Date.UTC().', + }, +]; + +const consoleRestrictedSelector = { + selector: "CallExpression[callee.object.name='console']", + message: + 'Do not use console.* — use ctx.logger for diagnostics (writes to stderr) and ctx.io.writeStdout for business output.', +}; + +const processRestrictedProperties = [ + { + object: 'process', + property: 'stdout', + message: 'Use ctx.io.writeStdout (see DI conventions in .claude/rules).', + }, + { + object: 'process', + property: 'stderr', + message: 'Use ctx.logger or ctx.io.writeStderr (see DI conventions in .claude/rules).', + }, +]; + +export default defineConfig( + ...baseConfig, + { + ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'claude-tmp/**', 'bun.lock'], + }, + { + rules: { + 'no-restricted-syntax': ['error', ...dateRestrictedSelectors], + }, + }, + { + files: ['src/**/*.ts'], + rules: { + 'no-restricted-syntax': ['error', ...dateRestrictedSelectors, consoleRestrictedSelector], + 'no-restricted-properties': ['error', ...processRestrictedProperties], + }, + }, +); diff --git a/package.json b/package.json index db2545a..fff587f 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,84 @@ { "name": "patchwave-analysis", - "module": "index.ts", + "version": "0.0.1", + "description": "Diagnostic CLI that measures Dependabot toil and CVE exposure across a GitHub org.", "type": "module", - "private": true, + "bin": { + "patchwave-analysis": "./src/index.ts" + }, + "files": [ + "src/**/*.ts", + "!src/**/*.test.ts", + "!src/**/testFactories.ts", + "!src/testHelpers", + "!src/report/web", + "dist/report-web/index.html", + "README.md" + ], + "scripts": { + "start": "bun run build:report-web && bun run src/index.ts", + "test": "bun run test:unit && bun run test:browser", + "test:unit": "bun run build:report-web && bun test ./src/*.test.ts ./src/**/*.test.ts", + "test:browser": "vitest run", + "typecheck": "bun run build:report-web && tsc --noEmit", + "lint": "eslint . --max-warnings 0 --cache", + "lint:fix": "eslint . --fix --cache", + "format": "prettier --write \"**/*.{ts,tsx,md,json,yaml,yml}\"", + "format:check": "prettier --check \"**/*.{ts,tsx,md,json,yaml,yml}\"", + "prepare": "husky", + "prepack": "bun run build:report-web", + "build:report-web": "bun run scripts/build-report-web.ts", + "dev:report-web": "bun ./src/report/web/index.dev.html", + "build:darwin-arm64": "bun run build:report-web && bun build --compile --target=bun-darwin-arm64 ./src/index.ts --outfile dist/patchwave-analysis-darwin-arm64", + "build:darwin-x64": "bun run build:report-web && bun build --compile --target=bun-darwin-x64 ./src/index.ts --outfile dist/patchwave-analysis-darwin-x64", + "build:linux-x64": "bun run build:report-web && bun build --compile --target=bun-linux-x64 ./src/index.ts --outfile dist/patchwave-analysis-linux-x64" + }, + "prettier": "@contextbridge-ai/prettier-config", + "lint-staged": { + "*.{ts,tsx}": "eslint --fix --no-warn-ignored", + "*": "prettier --write --ignore-unknown" + }, "devDependencies": { - "@types/bun": "latest" + "@contextbridge-ai/eslint-config": "^0.0.0", + "@contextbridge-ai/prettier-config": "^0.0.0", + "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/ibm-plex-sans": "^5.2.8", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/browser-playwright": "^4.0.18", + "@types/bun": "latest", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/semver": "^7.7.1", + "bun-plugin-tailwind": "^0.1.2", + "eslint": "^10.4.0", + "fishery": "^2.4.0", + "globals": "^17.6.0", + "husky": "^9.1.7", + "lint-staged": "^17.0.5", + "prettier": "^3.8.3", + "playwright": "^1.58.2", + "tailwindcss": "^4", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.4", + "vite": "^7.2.4", + "vitest": "^4.0.18" }, - "peerDependencies": { - "typescript": "^5" + "dependencies": { + "@js-temporal/polyfill": "^0.5.1", + "@octokit/graphql": "^9.0.3", + "@octokit/plugin-retry": "^8.1.0", + "@octokit/plugin-throttling": "^11.0.3", + "@octokit/rest": "^22.0.1", + "fflate": "^0.8.3", + "neverthrow": "^8.2.0", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", + "posthog-node": "^5.35.1", + "react": "^19", + "react-dom": "^19", + "semver": "^7.8.1", + "zod": "^4.4.3" } } diff --git a/tsconfig.json b/tsconfig.json index bfa0fea..ce4276f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { // Environment setup & latest features - "lib": ["ESNext"], + "lib": ["ESNext", "DOM", "DOM.Iterable"], "target": "ESNext", "module": "Preserve", "moduleDetection": "force", @@ -11,6 +11,7 @@ // Bundler mode "moduleResolution": "bundler", "allowImportingTsExtensions": true, + "resolveJsonModule": true, "verbatimModuleSyntax": true, "noEmit": true, @@ -25,5 +26,6 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false - } + }, + "exclude": ["node_modules", "dist", "claude-tmp", "out"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..786f00c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,37 @@ +import react from '@vitejs/plugin-react'; +import { playwright } from '@vitest/browser-playwright'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [react()], + resolve: { + dedupe: ['react', 'react-dom'], + }, + optimizeDeps: { + include: [ + '@testing-library/jest-dom/vitest', + '@testing-library/react', + 'react', + 'react-dom', + 'fishery', + '@js-temporal/polyfill', + ], + entries: ['src/report/web/**/*.{ts,tsx}'], + }, + test: { + // Browser-only component tests use the `.browser.test.tsx` suffix so the split + // from `bun test` (which owns `*.test.ts`) is obvious at a glance. + include: ['src/**/*.browser.test.tsx'], + setupFiles: ['./vitest.setup.ts'], + browser: { + enabled: true, + provider: playwright(), + instances: [ + { + browser: 'chromium', + headless: true, + }, + ], + }, + }, +}); diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..bb02c60 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest'; From edd14ac49cf3326653188bfd65bd6c828c55b476 Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Sun, 24 May 2026 08:48:44 -0600 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20core=20infrastructure=20=E2=80=94?= =?UTF-8?q?=20Context,=20IO,=20GitHub=20client,=20analytics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The injectable-dependency foundation everything else builds on, following the Context + XxxImpl/FakeXxx convention. - types.ts: shared domain types for collected data and the report - context.ts: Context interface + createContext() wiring - environment.ts, errors.ts, time.ts, concurrency.ts, logger.ts: cross-cutting primitives (env parsing, error helpers, Temporal wrappers, bounded concurrency, structured logging) - Clock, FileSystem, BaseIo/IoImpl: I/O dependency interfaces + production implementations - github/: GithubClient (REST/GraphQL), token resolution (auth.ts), and discriminated-union error mapping (errors.ts) - Analytics + anonymousId: opt-out PostHog telemetry with a stable anonymous id --- src/Analytics.test.ts | 119 +++++++++++++++++++++++++++++++++ src/Analytics.ts | 92 ++++++++++++++++++++++++++ src/BaseIo.ts | 42 ++++++++++++ src/Clock.ts | 11 ++++ src/FileSystem.ts | 34 ++++++++++ src/IoImpl.ts | 19 ++++++ src/anonymousId.test.ts | 42 ++++++++++++ src/anonymousId.ts | 40 ++++++++++++ src/concurrency.ts | 22 +++++++ src/context.ts | 47 +++++++++++++ src/environment.ts | 28 ++++++++ src/errors.ts | 7 ++ src/github/GithubClient.ts | 67 +++++++++++++++++++ src/github/auth.ts | 33 ++++++++++ src/github/errors.test.ts | 36 ++++++++++ src/github/errors.ts | 80 +++++++++++++++++++++++ src/github/testFactories.ts | 19 ++++++ src/logger.ts | 19 ++++++ src/time.ts | 12 ++++ src/types.ts | 127 ++++++++++++++++++++++++++++++++++++ 20 files changed, 896 insertions(+) create mode 100644 src/Analytics.test.ts create mode 100644 src/Analytics.ts create mode 100644 src/BaseIo.ts create mode 100644 src/Clock.ts create mode 100644 src/FileSystem.ts create mode 100644 src/IoImpl.ts create mode 100644 src/anonymousId.test.ts create mode 100644 src/anonymousId.ts create mode 100644 src/concurrency.ts create mode 100644 src/context.ts create mode 100644 src/environment.ts create mode 100644 src/errors.ts create mode 100644 src/github/GithubClient.ts create mode 100644 src/github/auth.ts create mode 100644 src/github/errors.test.ts create mode 100644 src/github/errors.ts create mode 100644 src/github/testFactories.ts create mode 100644 src/logger.ts create mode 100644 src/time.ts create mode 100644 src/types.ts diff --git a/src/Analytics.test.ts b/src/Analytics.test.ts new file mode 100644 index 0000000..14701e5 --- /dev/null +++ b/src/Analytics.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from 'bun:test'; +import { AnalyticsImpl, NoopAnalytics, type PostHogClient } from './Analytics.ts'; + +interface RecordedIdentify { + readonly distinctId: string | undefined; + readonly properties?: Record; +} + +interface RecordedCapture { + readonly distinctId: string | undefined; + readonly event: string; + readonly properties?: Record; +} + +interface FakeClient { + readonly client: PostHogClient; + readonly identify: RecordedIdentify[]; + readonly capture: RecordedCapture[]; +} + +function createFakeClient(): FakeClient { + const identify: RecordedIdentify[] = []; + const capture: RecordedCapture[] = []; + return { + identify, + capture, + client: { + identify: (input) => { + identify.push({ distinctId: input.distinctId, properties: input.properties }); + }, + capture: (input) => { + capture.push({ distinctId: input.distinctId, event: input.event, properties: input.properties }); + }, + flush: () => Promise.resolve(), + shutdown: () => Promise.resolve(), + }, + }; +} + +describe('AnalyticsImpl', () => { + test('stamps surface and version on identify and capture', () => { + const fake = createFakeClient(); + const a = new AnalyticsImpl({ distinctId: 'user-1', version: '0.0.1', client: fake.client }); + + a.identify('user-1'); + a.capture('run_started', { foo: 'bar' }); + + expect(fake.identify[0]).toMatchObject({ + distinctId: 'user-1', + properties: { pw_surface: 'cli', pw_version: '0.0.1' }, + }); + expect(fake.capture[0]).toMatchObject({ + distinctId: 'user-1', + event: 'run_started', + properties: { pw_surface: 'cli', pw_version: '0.0.1', foo: 'bar' }, + }); + }); + + test('register merges into subsequent calls', () => { + const fake = createFakeClient(); + const a = new AnalyticsImpl({ distinctId: 'user-1', version: '0.0.1', client: fake.client }); + + a.register({ pw_command: 'run' }); + a.capture('event'); + + expect(fake.capture[0]?.properties).toMatchObject({ pw_command: 'run' }); + }); + + test('capture always uses the constructor distinctId', () => { + const fake = createFakeClient(); + const a = new AnalyticsImpl({ distinctId: 'original', version: '0.0.1', client: fake.client }); + + a.identify('different'); + a.capture('event'); + + expect(fake.capture[0]?.distinctId).toBe('original'); + }); + + test('swallows thrown client errors so telemetry never breaks the CLI', () => { + const throwing: PostHogClient = { + identify: () => { + throw new Error('boom'); + }, + capture: () => { + throw new Error('boom'); + }, + flush: () => Promise.resolve(), + shutdown: () => Promise.resolve(), + }; + const a = new AnalyticsImpl({ distinctId: 'user-1', version: '0.0.1', client: throwing }); + + expect(() => a.identify('user-1')).not.toThrow(); + expect(() => a.capture('event')).not.toThrow(); + }); + + test('flush and shutdown swallow rejected promises', () => { + const rejecting: PostHogClient = { + identify: () => {}, + capture: () => {}, + flush: () => Promise.reject(new Error('flush boom')), + shutdown: () => Promise.reject(new Error('shutdown boom')), + }; + const a = new AnalyticsImpl({ distinctId: 'user-1', version: '0.0.1', client: rejecting }); + + expect(a.flush()).resolves.toBeUndefined(); + expect(a.shutdown()).resolves.toBeUndefined(); + }); +}); + +describe('NoopAnalytics', () => { + test('does nothing observable and never throws', () => { + const a = new NoopAnalytics(); + expect(() => a.identify('x')).not.toThrow(); + expect(() => a.capture('e')).not.toThrow(); + expect(() => a.register({ k: 'v' })).not.toThrow(); + expect(a.flush()).resolves.toBeUndefined(); + expect(a.shutdown()).resolves.toBeUndefined(); + }); +}); diff --git a/src/Analytics.ts b/src/Analytics.ts new file mode 100644 index 0000000..ee02845 --- /dev/null +++ b/src/Analytics.ts @@ -0,0 +1,92 @@ +import { ResultAsync, fromThrowable } from 'neverthrow'; +import { PostHog } from 'posthog-node'; + +export interface Analytics { + identify(distinctId: string, properties?: Record): void; + capture(event: string, properties?: Record): void; + register(properties: Record): void; + flush(): Promise; + shutdown(): Promise; +} + +export type PostHogClient = Pick; + +const POSTHOG_KEY = 'phc_nDFXB87pWC9QEJHLjtb38BbjboXaxnzkcTJ8SN3Dr62j'; +const POSTHOG_HOST = 'https://us.i.posthog.com'; + +export interface AnalyticsImplOptions { + readonly distinctId: string; + readonly version: string; + readonly client?: PostHogClient; +} + +export class AnalyticsImpl implements Analytics { + readonly #distinctId: string; + readonly #client: PostHogClient; + readonly #superProperties: Record; + readonly #safeIdentify: (input: Parameters[0]) => void; + readonly #safeCapture: (input: Parameters[0]) => void; + + constructor(options: AnalyticsImplOptions) { + this.#distinctId = options.distinctId; + this.#client = options.client ?? createDefaultClient(); + this.#superProperties = { + pw_surface: 'cli', + pw_version: options.version, + }; + // Wrap PostHog calls in neverthrow so a telemetry failure (network, bad + // payload) is explicit and can never escape into the CLI's control flow. + const safeIdentify = fromThrowable(this.#client.identify.bind(this.#client)); + const safeCapture = fromThrowable(this.#client.capture.bind(this.#client)); + this.#safeIdentify = (input) => { + void safeIdentify(input); + }; + this.#safeCapture = (input) => { + void safeCapture(input); + }; + } + + identify(distinctId: string, properties?: Record): void { + this.#safeIdentify({ + distinctId, + properties: { ...this.#superProperties, ...properties }, + }); + } + + capture(event: string, properties?: Record): void { + this.#safeCapture({ + distinctId: this.#distinctId, + event, + properties: { ...this.#superProperties, ...properties }, + }); + } + + register(properties: Record): void { + Object.assign(this.#superProperties, properties); + } + + async flush(): Promise { + await ResultAsync.fromPromise(this.#client.flush(), (err: unknown) => err).unwrapOr(undefined); + } + + async shutdown(): Promise { + await ResultAsync.fromPromise(this.#client.shutdown(), (err: unknown) => err).unwrapOr(undefined); + } +} + +export class NoopAnalytics implements Analytics { + identify(_distinctId: string, _properties?: Record): void {} + capture(_event: string, _properties?: Record): void {} + register(_properties: Record): void {} + async flush(): Promise {} + async shutdown(): Promise {} +} + +function createDefaultClient(): PostHog { + return new PostHog(POSTHOG_KEY, { + host: POSTHOG_HOST, + // Short flush window for short-lived CLI processes; flushAt=1 sends eagerly. + flushAt: 1, + flushInterval: 1000, + }); +} diff --git a/src/BaseIo.ts b/src/BaseIo.ts new file mode 100644 index 0000000..36ce144 --- /dev/null +++ b/src/BaseIo.ts @@ -0,0 +1,42 @@ +import type { Writable } from 'node:stream'; + +export interface Writer extends Writable { + readonly isTTY?: boolean; +} + +export interface Io { + /** + * Raw stdout stream. Prefer `writeStdout(chunk)` — this field exists for + * library adapters and tests that need stream-level access. + */ + readonly stdout: Writer; + /** + * Raw stderr stream. Prefer `writeStderr(chunk)` — same rationale as stdout. + */ + readonly stderr: Writer; + writeStdout(chunk: string): void; + writeStderr(chunk: string): void; +} + +export interface BaseIoOptions { + readonly stdout: Writer; + readonly stderr: Writer; +} + +export abstract class BaseIo implements Io { + readonly stdout: Writer; + readonly stderr: Writer; + + protected constructor(options: BaseIoOptions) { + this.stdout = options.stdout; + this.stderr = options.stderr; + } + + writeStdout(chunk: string): void { + this.stdout.write(chunk); + } + + writeStderr(chunk: string): void { + this.stderr.write(chunk); + } +} diff --git a/src/Clock.ts b/src/Clock.ts new file mode 100644 index 0000000..13057c1 --- /dev/null +++ b/src/Clock.ts @@ -0,0 +1,11 @@ +import { type Instant, nowInstant } from './time.ts'; + +export interface Clock { + now(): Instant; +} + +export class ClockImpl implements Clock { + now(): Instant { + return nowInstant(); + } +} diff --git a/src/FileSystem.ts b/src/FileSystem.ts new file mode 100644 index 0000000..bfd432d --- /dev/null +++ b/src/FileSystem.ts @@ -0,0 +1,34 @@ +import { ResultAsync } from 'neverthrow'; +import { toError } from './errors.ts'; + +export type FsError = { kind: 'write-failed'; path: string; message: string }; + +export interface FileSystem { + writeTextFile(path: string, contents: string): ResultAsync; + writeBinaryFile(path: string, contents: Uint8Array): ResultAsync; +} + +export class FileSystemImpl implements FileSystem { + writeTextFile(path: string, contents: string): ResultAsync { + return this.write(path, contents); + } + + writeBinaryFile(path: string, contents: Uint8Array): ResultAsync { + return this.write(path, contents); + } + + private write(path: string, contents: string | Uint8Array): ResultAsync { + return ResultAsync.fromPromise( + Bun.write(path, contents).then(() => undefined), + (e): FsError => ({ + kind: 'write-failed', + path, + message: toError(e).message, + }), + ); + } +} + +export function formatFsError(err: FsError): string { + return `failed to write ${err.path}: ${err.message}`; +} diff --git a/src/IoImpl.ts b/src/IoImpl.ts new file mode 100644 index 0000000..9a195e7 --- /dev/null +++ b/src/IoImpl.ts @@ -0,0 +1,19 @@ +import { BaseIo, type Writer } from './BaseIo.ts'; + +export type { Io, Writer } from './BaseIo.ts'; + +export interface IoImplOptions { + readonly stdout?: Writer; + readonly stderr?: Writer; +} + +export class IoImpl extends BaseIo { + constructor(options: IoImplOptions = {}) { + // The one place that touches the real process streams — everything downstream + // receives them through ctx.io. + /* eslint-disable no-restricted-properties */ + const { stdout = process.stdout, stderr = process.stderr } = options; + /* eslint-enable no-restricted-properties */ + super({ stdout, stderr }); + } +} diff --git a/src/anonymousId.test.ts b/src/anonymousId.test.ts new file mode 100644 index 0000000..3c23bbc --- /dev/null +++ b/src/anonymousId.test.ts @@ -0,0 +1,42 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, test } from 'bun:test'; +import { getOrCreateAnonymousId } from './anonymousId.ts'; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'patchwave-anonid-')); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +test('creates a new UUID when the file is missing and persists it', () => { + const id = getOrCreateAnonymousId({ XDG_CONFIG_HOME: dir }); + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + + const persisted = readFileSync(join(dir, 'contextbridge', 'anonymous_id'), 'utf8').trim(); + expect(persisted).toBe(id); +}); + +test('returns the existing id on subsequent calls', () => { + const first = getOrCreateAnonymousId({ XDG_CONFIG_HOME: dir }); + const second = getOrCreateAnonymousId({ XDG_CONFIG_HOME: dir }); + expect(second).toBe(first); +}); + +test('falls back to $HOME/.config when XDG_CONFIG_HOME is unset', () => { + const id = getOrCreateAnonymousId({ HOME: dir }); + expect(id.length).toBeGreaterThan(0); + const persisted = readFileSync(join(dir, '.config', 'contextbridge', 'anonymous_id'), 'utf8').trim(); + expect(persisted).toBe(id); +}); + +test('reads a pre-existing id file written by another tool', async () => { + await Bun.write(join(dir, 'contextbridge', 'anonymous_id'), 'preexisting-id\n'); + const id = getOrCreateAnonymousId({ XDG_CONFIG_HOME: dir }); + expect(id).toBe('preexisting-id'); +}); diff --git a/src/anonymousId.ts b/src/anonymousId.ts new file mode 100644 index 0000000..efebd89 --- /dev/null +++ b/src/anonymousId.ts @@ -0,0 +1,40 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { fromThrowable } from 'neverthrow'; + +const APP_DIR_NAME = 'contextbridge'; +const FILE_NAME = 'anonymous_id'; + +export interface AnonymousIdEnv { + readonly XDG_CONFIG_HOME?: string; + readonly HOME?: string; +} + +const safeRead = fromThrowable((path: string) => readFileSync(path, 'utf8').trim()); +const safeWrite = fromThrowable((dir: string, path: string, id: string) => { + mkdirSync(dir, { recursive: true }); + writeFileSync(path, `${id}\n`, { encoding: 'utf8', mode: 0o600 }); +}); + +export function getOrCreateAnonymousId(env: AnonymousIdEnv): string { + const dir = configDir(env); + const path = join(dir, FILE_NAME); + + const existing = safeRead(path).unwrapOr(''); + if (existing.length > 0) return existing; + + const id = crypto.randomUUID(); + // A read-only filesystem shouldn't break telemetry — discard the write + // Result and return the generated id either way. + safeWrite(dir, path, id); + return id; +} + +function configDir(env: AnonymousIdEnv): string { + if (env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.length > 0) { + return join(env.XDG_CONFIG_HOME, APP_DIR_NAME); + } + const home = env.HOME && env.HOME.length > 0 ? env.HOME : homedir(); + return join(home, '.config', APP_DIR_NAME); +} diff --git a/src/concurrency.ts b/src/concurrency.ts new file mode 100644 index 0000000..792c1e3 --- /dev/null +++ b/src/concurrency.ts @@ -0,0 +1,22 @@ +export async function mapWithConcurrency( + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + const results: U[] = new Array(items.length); + let cursor = 0; + + async function worker(): Promise { + while (true) { + const i = cursor++; + if (i >= items.length) return; + const item = items[i]; + if (item === undefined) continue; + results[i] = await fn(item, i); + } + } + + const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker); + await Promise.all(workers); + return results; +} diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..b17beae --- /dev/null +++ b/src/context.ts @@ -0,0 +1,47 @@ +import type { Analytics } from './Analytics.ts'; +import { NoopAnalytics } from './Analytics.ts'; +import type { Io } from './BaseIo.ts'; +import type { Clock } from './Clock.ts'; +import { ClockImpl } from './Clock.ts'; +import { type Environment, getEnvironment } from './environment.ts'; +import type { FileSystem } from './FileSystem.ts'; +import { FileSystemImpl } from './FileSystem.ts'; +import type { GithubClient } from './github/GithubClient.ts'; +import { GithubClientImpl } from './github/GithubClient.ts'; +import { IoImpl } from './IoImpl.ts'; +import { type Logger, createLogger } from './logger.ts'; + +export interface Context { + readonly io: Io; + readonly logger: Logger; + readonly env: Environment; + readonly clock: Clock; + readonly fs: FileSystem; + readonly githubClient: GithubClient; + readonly analytics: Analytics; +} + +export interface CreateContextOptions { + readonly token: string; + readonly io?: Io; + readonly logger?: Logger; + readonly env?: Environment; + readonly clock?: Clock; + readonly fs?: FileSystem; + readonly githubClient?: GithubClient; + readonly analytics?: Analytics; +} + +export function createContext(options: CreateContextOptions): Context { + const { + token, + io = new IoImpl(), + env = getEnvironment(), + clock = new ClockImpl(), + fs = new FileSystemImpl(), + analytics = new NoopAnalytics(), + } = options; + const logger = options.logger ?? createLogger({ level: env.LOG_LEVEL, destination: io.stderr }); + const githubClient = options.githubClient ?? new GithubClientImpl({ token, logger }); + return { io, logger, env, clock, fs, githubClient, analytics }; +} diff --git a/src/environment.ts b/src/environment.ts new file mode 100644 index 0000000..6f401a9 --- /dev/null +++ b/src/environment.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +const booleanEnv = z.stringbool({ truthy: ['1', 'true', 'yes'], falsy: ['', '0', 'false', 'no'] }).default(false); + +const EnvironmentSchema = z.object({ + LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent']).default('info'), + DO_NOT_TRACK: booleanEnv, + CONTEXTBRIDGE_TELEMETRY_DISABLED: booleanEnv, + CI: booleanEnv, + XDG_CONFIG_HOME: z.string().optional(), + HOME: z.string().optional(), +}); + +export type Environment = z.infer; + +export function getEnvironment(env: NodeJS.ProcessEnv = process.env): Environment { + return EnvironmentSchema.parse(env); +} + +export interface TelemetryOptOutEnv { + readonly DO_NOT_TRACK?: boolean; + readonly CONTEXTBRIDGE_TELEMETRY_DISABLED?: boolean; + readonly CI?: boolean; +} + +export function isTelemetryDisabled(env: TelemetryOptOutEnv): boolean { + return Boolean(env.DO_NOT_TRACK || env.CONTEXTBRIDGE_TELEMETRY_DISABLED || env.CI); +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..4ef51dd --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,7 @@ +export function toError(err: unknown): Error { + return err instanceof Error ? err : new Error(String(err)); +} + +export function getErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/github/GithubClient.ts b/src/github/GithubClient.ts new file mode 100644 index 0000000..4bc75f0 --- /dev/null +++ b/src/github/GithubClient.ts @@ -0,0 +1,67 @@ +import { graphql as graphqlBase } from '@octokit/graphql'; +import { retry } from '@octokit/plugin-retry'; +import { throttling } from '@octokit/plugin-throttling'; +import { Octokit } from '@octokit/rest'; +import { ResultAsync } from 'neverthrow'; +import type { Logger } from '../logger.ts'; +import { type GithubError, toGithubError } from './errors.ts'; + +const PatchwaveOctokit = Octokit.plugin(retry, throttling); + +function noop(): void {} + +/** + * Narrow GitHub API surface the collectors depend on. Each method returns a + * ResultAsync so callers can chain without try/catch. Production wires this to + * Octokit + @octokit/graphql; tests use FakeGithubClient. + */ +export interface GithubClient { + paginate(route: string, params?: Record): ResultAsync; + request(route: string, params?: Record): ResultAsync; + graphql(query: string, variables?: Record): ResultAsync; +} + +export interface GithubClientImplOptions { + readonly token: string; + readonly logger: Logger; + readonly userAgent?: string; +} + +export class GithubClientImpl implements GithubClient { + private readonly rest: InstanceType; + private readonly graphqlClient: typeof graphqlBase; + + constructor(options: GithubClientImplOptions) { + const { token, logger, userAgent = 'patchwave-analysis' } = options; + this.rest = new PatchwaveOctokit({ + auth: token, + userAgent, + log: { + debug: noop, + info: noop, + warn: noop, + error: (msg: string) => logger.error({ source: 'octokit' }, msg), + }, + retry: { doNotRetry: [400, 401, 403, 404, 409, 422] }, + throttle: { + onRateLimit: (_retryAfter, _opts, _octokit, retryCount) => retryCount < 2, + onSecondaryRateLimit: () => true, + }, + }); + this.graphqlClient = graphqlBase.defaults({ + headers: { authorization: `token ${token}` }, + }); + } + + paginate(route: string, params: Record = {}): ResultAsync { + return ResultAsync.fromPromise(this.rest.paginate(route, params), toGithubError); + } + + request(route: string, params: Record = {}): ResultAsync { + return ResultAsync.fromPromise(this.rest.request(route, params), toGithubError).map((res) => res.data as T); + } + + graphql(query: string, variables: Record = {}): ResultAsync { + return ResultAsync.fromPromise(this.graphqlClient(query, variables), toGithubError); + } +} diff --git a/src/github/auth.ts b/src/github/auth.ts new file mode 100644 index 0000000..caae6ab --- /dev/null +++ b/src/github/auth.ts @@ -0,0 +1,33 @@ +import { $ } from 'bun'; +import { ResultAsync, errAsync, okAsync } from 'neverthrow'; +import { getErrorMessage } from '../errors.ts'; + +export type AuthError = { kind: 'no-token'; message: string } | { kind: 'gh-failed'; message: string }; + +export function resolveToken(): ResultAsync { + const envToken = (Bun.env.GITHUB_TOKEN ?? Bun.env.GH_TOKEN ?? '').trim(); + if (envToken.length > 0) return okAsync(envToken); + + return ResultAsync.fromPromise( + $`gh auth token`.quiet().text(), + (e): AuthError => ({ kind: 'gh-failed', message: getErrorMessage(e) }), + ).andThen((raw) => { + const token = raw.trim(); + if (token.length === 0) { + return errAsync({ + kind: 'no-token', + message: 'gh auth token returned an empty string', + }); + } + return okAsync(token); + }); +} + +export function formatAuthError(err: AuthError): string { + switch (err.kind) { + case 'no-token': + return `no GitHub token available.\n fix: set GITHUB_TOKEN, or run 'gh auth login' to use the gh CLI`; + case 'gh-failed': + return `failed to read token from gh CLI: ${err.message}\n fix: set GITHUB_TOKEN, or run 'gh auth login'`; + } +} diff --git a/src/github/errors.test.ts b/src/github/errors.test.ts new file mode 100644 index 0000000..834a846 --- /dev/null +++ b/src/github/errors.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from 'bun:test'; +import { toGithubError } from './errors.ts'; +import { requestError } from './testFactories.ts'; + +test('403 with a present scope that matches an accepted scope is forbidden, not scope-missing', () => { + // GitHub's x-accepted-oauth-scopes header is OR semantics — any one scope in + // the list grants access. If the token has any of them, this 403 is not a + // token problem (it's typically a per-repo feature disabled / permission). + const err = requestError.build({ + response: { + headers: { + 'x-accepted-oauth-scopes': 'admin:repo_hook, repo, security_events', + 'x-oauth-scopes': 'gist, read:org, repo', + }, + }, + message: 'Dependabot alerts are disabled for this repository.', + }); + expect(toGithubError(err)).toMatchObject({ kind: 'forbidden' }); +}); + +test('403 with no overlapping scopes is scope-missing, reporting the first accepted scope', () => { + const err = requestError.build({ + response: { + headers: { + 'x-accepted-oauth-scopes': 'security_events, repo', + 'x-oauth-scopes': 'read:org', + }, + }, + }); + expect(toGithubError(err)).toMatchObject({ kind: 'scope-missing', required: 'security_events' }); +}); + +test('403 with no x-accepted-oauth-scopes header is forbidden', () => { + const err = requestError.build({ response: { headers: {} } }); + expect(toGithubError(err)).toMatchObject({ kind: 'forbidden' }); +}); diff --git a/src/github/errors.ts b/src/github/errors.ts new file mode 100644 index 0000000..e204507 --- /dev/null +++ b/src/github/errors.ts @@ -0,0 +1,80 @@ +import { getErrorMessage } from '../errors.ts'; + +export type GithubError = + | { kind: 'network'; message: string; cause: Error } + | { kind: 'not-found'; url?: string; message: string } + | { kind: 'scope-missing'; required: string; message: string } + | { kind: 'forbidden'; url?: string; message: string } + | { kind: 'http'; status: number; url?: string; message: string }; + +interface RequestErrorLike { + status?: number; + message?: string; + request?: { url?: string }; + response?: { headers?: Record }; +} + +export function toGithubError(err: unknown): GithubError { + const e = err as RequestErrorLike & Error; + const url = e?.request?.url; + const status = typeof e?.status === 'number' ? e.status : undefined; + const message = getErrorMessage(err); + + if (status === undefined) { + return { kind: 'network', message, cause: err instanceof Error ? err : new Error(message) }; + } + + if (status === 404) return { kind: 'not-found', url, message }; + + if (status === 403) { + const required = detectMissingScope(e); + if (required) { + return { + kind: 'scope-missing', + required, + message: `GitHub returned 403 for ${url ?? 'an API call'}; the access token is missing the '${required}' scope.`, + }; + } + return { kind: 'forbidden', url, message }; + } + + return { kind: 'http', status, url, message }; +} + +function detectMissingScope(err: RequestErrorLike): string | null { + const accepted = err.response?.headers?.['x-accepted-oauth-scopes']; + const present = err.response?.headers?.['x-oauth-scopes']; + if (!accepted) return null; + const acceptedScopes = accepted + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (acceptedScopes.length === 0) return null; + const presentScopes = new Set( + (present ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), + ); + // x-accepted-oauth-scopes is OR semantics: any one of the listed scopes + // grants access. If the token has any of them, this 403 isn't a scope + // problem — GitHub also returns 403 for things like per-repo features + // being disabled or insufficient repo-level permissions. + if (acceptedScopes.some((s) => presentScopes.has(s))) return null; + return acceptedScopes[0] ?? null; +} + +export function formatGithubError(err: GithubError): string { + switch (err.kind) { + case 'network': + return `network error talking to GitHub: ${err.message}`; + case 'not-found': + return `GitHub returned 404 for ${err.url ?? 'an API call'}`; + case 'scope-missing': + return `${err.message}\n fix: gh auth refresh -s ${err.required}`; + case 'forbidden': + return `GitHub returned 403 for ${err.url ?? 'an API call'}: ${err.message}`; + case 'http': + return `GitHub returned ${err.status} for ${err.url ?? 'an API call'}: ${err.message}`; + } +} diff --git a/src/github/testFactories.ts b/src/github/testFactories.ts new file mode 100644 index 0000000..bc18783 --- /dev/null +++ b/src/github/testFactories.ts @@ -0,0 +1,19 @@ +import { Factory } from 'fishery'; + +export interface RequestErrorLike { + status: number; + message: string; + request: { url: string }; + response: { headers: Record }; +} + +// Octokit surfaces failures as an Error carrying status/request/response. Fishery +// preserves the Error prototype because the generator returns a non-plain object, +// so overrides merge onto a real Error instance. +export const requestError = Factory.define(() => { + const err = new Error('forbidden') as Error & RequestErrorLike; + err.status = 403; + err.request = { url: 'https://api.github.com/test' }; + err.response = { headers: {} }; + return err; +}); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..dd9e2e0 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,19 @@ +import pino, { type LevelWithSilent, type Logger } from 'pino'; +import pinoPretty from 'pino-pretty'; + +export type { Logger, LevelWithSilent }; + +export interface CreateLoggerOptions { + readonly level: LevelWithSilent; + readonly destination: NodeJS.WritableStream & { isTTY?: boolean }; +} + +export function createLogger({ level, destination }: CreateLoggerOptions): Logger { + // pino-pretty as a sync stream (not a worker transport) so compiled + // single-file Bun binaries keep working — transport workers need runtime + // module resolution that bundled binaries can't satisfy. + const sink = destination.isTTY + ? pinoPretty({ colorize: true, destination: destination as NodeJS.WritableStream }) + : destination; + return pino({ level }, sink); +} diff --git a/src/time.ts b/src/time.ts new file mode 100644 index 0000000..c3f5645 --- /dev/null +++ b/src/time.ts @@ -0,0 +1,12 @@ +import { Temporal as PolyfillTemporal } from '@js-temporal/polyfill'; + +export const Temporal = PolyfillTemporal; +export type Instant = ReturnType<(typeof Temporal.Now)['instant']>; + +export function nowInstant(): Instant { + return Temporal.Now.instant(); +} + +export function instantFromString(value: string): Instant { + return Temporal.Instant.from(value); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..0e742bb --- /dev/null +++ b/src/types.ts @@ -0,0 +1,127 @@ +import type { Instant } from './time.ts'; + +export type Visibility = 'public' | 'private' | 'internal'; + +export interface RepoRef { + owner: string; + name: string; +} + +export interface RepoMeta extends RepoRef { + visibility: Visibility; + archived: boolean; + defaultBranch: string; + primaryLanguage: string | null; + pushedAt: string | null; + dependabotSecurityUpdates: boolean | null; +} + +export interface LanguageBytes { + [language: string]: number; +} + +export interface RepoLanguages extends RepoRef { + bytes: LanguageBytes; +} + +export type DependabotEcosystem = string; + +export type DependabotInterval = 'daily' | 'weekly' | 'monthly'; + +export interface DependabotUpdateEntry { + ecosystem: DependabotEcosystem; + interval: DependabotInterval | null; + openPullRequestsLimit: number; + groupCount: number; + ignoreCount: number; +} + +export interface DependabotConfigSlice extends RepoRef { + hasConfig: boolean; + ecosystems: DependabotEcosystem[]; + updates: DependabotUpdateEntry[]; +} + +export type PrState = 'open' | 'closed'; + +export interface CheckSummary { + total: number; + success: number; + failure: number; + pending: number; + failedCheckNames: string[]; +} + +export interface DependabotPr extends RepoRef { + number: number; + title: string; + state: PrState; + merged: boolean; + createdAt: string; + closedAt: string | null; + mergedAt: string | null; + mergedBy: string | null; + headRef: string; + baseRef: string; + htmlUrl: string; + reviewers: string[]; + commenters: string[]; + autoMergeEnabled: boolean; + checks: CheckSummary; +} + +export type CveSeverity = 'critical' | 'high' | 'medium' | 'low'; + +export interface CveAlert extends RepoRef { + number: number; + severity: CveSeverity; + createdAt: string; + packageName: string; + ecosystem: string; + summary: string; +} + +export type CveSlice = RepoRef & + ( + | { status: 'ok'; alerts: CveAlert[] } + | { status: 'scope-missing'; requiredScope: string } + | { status: 'not-enabled' } + ); + +export type BranchProtectionSource = 'classic' | 'ruleset'; + +export interface BranchProtectionSlice extends RepoRef { + hasProtection: boolean; + sources: BranchProtectionSource[]; + requiredApprovingReviewCount: number | null; + requiresStatusChecks: boolean; +} + +export interface ContributorSlice extends RepoRef { + activeHumanLogins: string[]; +} + +export interface CollectionContext { + org: string; + windowDays: number; + windowStart: Instant; + now: Instant; +} + +export interface CollectedData { + ctx: CollectionContext; + repos: RepoMeta[]; + languages: RepoLanguages[]; + dependabotConfig: DependabotConfigSlice[]; + dependabotPrs: DependabotPr[]; + cve: CveSlice[]; + branchProtection: BranchProtectionSlice[]; + contributors: ContributorSlice[]; + errors: CollectorWarning[]; +} + +export interface CollectorWarning { + collector: string; + repo?: RepoRef; + message: string; +} From acbd09f5ea1b9daaa2fac32b7be743189df8cf95 Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Sun, 24 May 2026 08:48:49 -0600 Subject: [PATCH 3/7] test: shared fakes and data factories Deterministic test doubles implementing the Context dependency interfaces, swapped in without module mocking: - testHelpers/Fake{Analytics,Clock,FileSystem,GithubClient,Io}.ts: inspectable fakes for each injectable dependency - testHelpers/MemoryStream.ts: in-memory writable for IO assertions - testHelpers/createFakeContext.ts: assembles a fully-faked Context - testFactories.ts: builders for domain fixtures used across tests --- src/testFactories.ts | 142 +++++++++++++++++++++++++++ src/testHelpers/FakeAnalytics.ts | 30 ++++++ src/testHelpers/FakeClock.ts | 14 +++ src/testHelpers/FakeFileSystem.ts | 44 +++++++++ src/testHelpers/FakeGithubClient.ts | 121 +++++++++++++++++++++++ src/testHelpers/FakeIo.ts | 11 +++ src/testHelpers/MemoryStream.ts | 14 +++ src/testHelpers/createFakeContext.ts | 42 ++++++++ src/testHelpers/index.ts | 2 + 9 files changed, 420 insertions(+) create mode 100644 src/testFactories.ts create mode 100644 src/testHelpers/FakeAnalytics.ts create mode 100644 src/testHelpers/FakeClock.ts create mode 100644 src/testHelpers/FakeFileSystem.ts create mode 100644 src/testHelpers/FakeGithubClient.ts create mode 100644 src/testHelpers/FakeIo.ts create mode 100644 src/testHelpers/MemoryStream.ts create mode 100644 src/testHelpers/createFakeContext.ts create mode 100644 src/testHelpers/index.ts diff --git a/src/testFactories.ts b/src/testFactories.ts new file mode 100644 index 0000000..4363f47 --- /dev/null +++ b/src/testFactories.ts @@ -0,0 +1,142 @@ +import { Factory } from 'fishery'; +import { instantFromString } from './time.ts'; +import type { + BranchProtectionSlice, + CheckSummary, + CollectedData, + CollectionContext, + CollectorWarning, + ContributorSlice, + CveAlert, + CveSlice, + DependabotConfigSlice, + DependabotPr, + DependabotUpdateEntry, + RepoLanguages, + RepoMeta, + RepoRef, +} from './types.ts'; + +export const repoRef = Factory.define(() => ({ + owner: 'acme', + name: 'widgets', +})); + +export const repoMeta = Factory.define(() => ({ + owner: 'acme', + name: 'widgets', + visibility: 'private', + archived: false, + defaultBranch: 'main', + primaryLanguage: 'TypeScript', + pushedAt: '2026-04-01T00:00:00Z', + dependabotSecurityUpdates: true, +})); + +export const checkSummary = Factory.define(() => ({ + total: 0, + success: 0, + failure: 0, + pending: 0, + failedCheckNames: [], +})); + +export const dependabotPr = Factory.define(({ sequence }) => ({ + owner: 'acme', + name: 'widgets', + number: sequence, + title: `Bump lodash from 4.17.20 to 4.17.21`, + state: 'open', + merged: false, + createdAt: '2026-04-01T00:00:00Z', + closedAt: null, + mergedAt: null, + mergedBy: null, + headRef: `dependabot/npm_and_yarn/lodash-4.17.21`, + baseRef: 'main', + htmlUrl: `https://github.com/acme/widgets/pull/${sequence}`, + reviewers: [], + commenters: [], + autoMergeEnabled: false, + checks: checkSummary.build(), +})); + +export const cveAlert = Factory.define(({ sequence }) => ({ + owner: 'acme', + name: 'widgets', + number: sequence, + severity: 'high', + createdAt: '2026-03-01T00:00:00Z', + packageName: 'lodash', + ecosystem: 'npm', + summary: 'Prototype pollution', +})); + +export const cveSliceOk = Factory.define>(() => ({ + owner: 'acme', + name: 'widgets', + status: 'ok', + alerts: [], +})); + +export const branchProtectionSlice = Factory.define(() => ({ + owner: 'acme', + name: 'widgets', + hasProtection: true, + sources: ['classic'], + requiredApprovingReviewCount: 1, + requiresStatusChecks: true, +})); + +export const contributorSlice = Factory.define(() => ({ + owner: 'acme', + name: 'widgets', + activeHumanLogins: [], +})); + +export const dependabotUpdateEntry = Factory.define(() => ({ + ecosystem: 'npm', + interval: 'weekly', + openPullRequestsLimit: 5, + groupCount: 0, + ignoreCount: 0, +})); + +export const dependabotConfigSlice = Factory.define(() => ({ + owner: 'acme', + name: 'widgets', + hasConfig: true, + ecosystems: ['npm'], + updates: [dependabotUpdateEntry.build()], +})); + +export const repoLanguages = Factory.define(() => ({ + owner: 'acme', + name: 'widgets', + bytes: { TypeScript: 100_000, JavaScript: 20_000 }, +})); + +export const collectorWarning = Factory.define(() => ({ + collector: 'branchProtection', + repo: { owner: 'acme', name: 'widgets' }, + message: 'GitHub returned 500', +})); + +export const collectionContext = Factory.define(() => ({ + org: 'acme', + windowDays: 90, + windowStart: instantFromString('2026-02-21T00:00:00Z'), + now: instantFromString('2026-05-22T00:00:00Z'), +})); + +export const collectedData = Factory.define(() => ({ + ctx: collectionContext.build(), + repos: [repoMeta.build()], + languages: [repoLanguages.build()], + dependabotConfig: [dependabotConfigSlice.build()], + dependabotPrs: [], + cve: [cveSliceOk.build()], + branchProtection: [branchProtectionSlice.build()], + contributors: [contributorSlice.build()], + errors: [], +})); diff --git a/src/testHelpers/FakeAnalytics.ts b/src/testHelpers/FakeAnalytics.ts new file mode 100644 index 0000000..2c6c2f6 --- /dev/null +++ b/src/testHelpers/FakeAnalytics.ts @@ -0,0 +1,30 @@ +import type { Analytics } from '../Analytics.ts'; + +export interface CaptureCall { + readonly event: string; + readonly properties?: Record; +} + +export class FakeAnalytics implements Analytics { + readonly captureCalls: CaptureCall[] = []; + + identify(_distinctId: string, _properties?: Record): void {} + + capture(event: string, properties?: Record): void { + this.captureCalls.push({ event, properties }); + } + + register(_properties: Record): void {} + + flush(): Promise { + return Promise.resolve(); + } + + shutdown(): Promise { + return Promise.resolve(); + } + + capturedEvents(name: string): CaptureCall[] { + return this.captureCalls.filter((c) => c.event === name); + } +} diff --git a/src/testHelpers/FakeClock.ts b/src/testHelpers/FakeClock.ts new file mode 100644 index 0000000..5054db5 --- /dev/null +++ b/src/testHelpers/FakeClock.ts @@ -0,0 +1,14 @@ +import type { Clock } from '../Clock.ts'; +import { type Instant, instantFromString } from '../time.ts'; + +export class FakeClock implements Clock { + private current: Instant; + + constructor(initial: Instant | string = '2026-05-22T00:00:00Z') { + this.current = typeof initial === 'string' ? instantFromString(initial) : initial; + } + + now(): Instant { + return this.current; + } +} diff --git a/src/testHelpers/FakeFileSystem.ts b/src/testHelpers/FakeFileSystem.ts new file mode 100644 index 0000000..8f7cf63 --- /dev/null +++ b/src/testHelpers/FakeFileSystem.ts @@ -0,0 +1,44 @@ +import { ResultAsync, errAsync, okAsync } from 'neverthrow'; +import type { FileSystem, FsError } from '../FileSystem.ts'; + +export interface FakeWrite { + readonly path: string; + readonly contents: string | Uint8Array; +} + +export class FakeFileSystem implements FileSystem { + readonly writes: FakeWrite[] = []; + private failure: FsError | null = null; + + failNextWriteWith(err: FsError): void { + this.failure = err; + } + + writeTextFile(path: string, contents: string): ResultAsync { + return this.recordWrite(path, contents); + } + + writeBinaryFile(path: string, contents: Uint8Array): ResultAsync { + return this.recordWrite(path, contents); + } + + read(path: string): string | undefined { + const contents = this.writes.find((w) => w.path === path)?.contents; + return typeof contents === 'string' ? contents : undefined; + } + + readBinary(path: string): Uint8Array | undefined { + const contents = this.writes.find((w) => w.path === path)?.contents; + return typeof contents === 'string' ? undefined : contents; + } + + private recordWrite(path: string, contents: string | Uint8Array): ResultAsync { + if (this.failure) { + const err = this.failure; + this.failure = null; + return errAsync(err); + } + this.writes.push({ path, contents }); + return okAsync(undefined); + } +} diff --git a/src/testHelpers/FakeGithubClient.ts b/src/testHelpers/FakeGithubClient.ts new file mode 100644 index 0000000..2d68c98 --- /dev/null +++ b/src/testHelpers/FakeGithubClient.ts @@ -0,0 +1,121 @@ +import { ResultAsync, errAsync, okAsync } from 'neverthrow'; +import type { GithubError } from '../github/errors.ts'; +import type { GithubClient } from '../github/GithubClient.ts'; + +export type GithubCall = + | { kind: 'paginate'; route: string; params: Record } + | { kind: 'request'; route: string; params: Record } + | { kind: 'graphql'; query: string; variables: Record }; + +type Outcome = { kind: 'ok'; value: unknown } | { kind: 'err'; error: GithubError }; + +interface ParamResponder { + readonly route: string; + readonly paramsMatcher: Record; + readonly outcome: Outcome; + readonly label: string; +} + +interface GraphqlResponder { + readonly match: (query: string, variables: Record) => boolean; + readonly outcome: Outcome; + readonly label: string; +} + +export interface Stub { + resolves(value: unknown): void; + fails(error: GithubError): void; +} + +/** + * Records each GitHub call and returns scripted responses by (route, params). + * Last matching responder wins, so tests can register a default and override + * narrower cases on top. + */ +export class FakeGithubClient implements GithubClient { + readonly calls: GithubCall[] = []; + private readonly paginateResponders: ParamResponder[] = []; + private readonly requestResponders: ParamResponder[] = []; + private readonly graphqlResponders: GraphqlResponder[] = []; + + onPaginate(route: string, paramsMatcher: Record = {}): Stub { + return this.makeStub(this.paginateResponders, route, paramsMatcher); + } + + onRequest(route: string, paramsMatcher: Record = {}): Stub { + return this.makeStub(this.requestResponders, route, paramsMatcher); + } + + onGraphql(match: ((query: string, variables: Record) => boolean) | string): Stub { + const matcher = typeof match === 'function' ? match : (q: string) => q.includes(match); + const label = typeof match === 'function' ? `onGraphql()` : `onGraphql(${JSON.stringify(match)})`; + return { + resolves: (value) => this.graphqlResponders.push({ match: matcher, outcome: { kind: 'ok', value }, label }), + fails: (error) => this.graphqlResponders.push({ match: matcher, outcome: { kind: 'err', error }, label }), + }; + } + + paginate(route: string, params: Record = {}): ResultAsync { + this.calls.push({ kind: 'paginate', route, params }); + return this.respond(this.paginateResponders, 'paginate', route, params); + } + + request(route: string, params: Record = {}): ResultAsync { + this.calls.push({ kind: 'request', route, params }); + return this.respond(this.requestResponders, 'request', route, params); + } + + graphql(query: string, variables: Record = {}): ResultAsync { + this.calls.push({ kind: 'graphql', query, variables }); + const responder = this.graphqlResponders.findLast((r) => r.match(query, variables)); + if (!responder) { + throw new Error( + `FakeGithubClient: no responder for graphql call. Registered: ${formatList( + this.graphqlResponders.map((r) => r.label), + )}`, + ); + } + return responder.outcome.kind === 'ok' ? okAsync(responder.outcome.value as T) : errAsync(responder.outcome.error); + } + + callsTo(kind: GithubCall['kind']): GithubCall[] { + return this.calls.filter((c) => c.kind === kind); + } + + private makeStub(bucket: ParamResponder[], route: string, paramsMatcher: Record): Stub { + const label = `${route} ${JSON.stringify(paramsMatcher)}`; + return { + resolves: (value) => bucket.push({ route, paramsMatcher, outcome: { kind: 'ok', value }, label }), + fails: (error) => bucket.push({ route, paramsMatcher, outcome: { kind: 'err', error }, label }), + }; + } + + private respond( + bucket: ParamResponder[], + kind: string, + route: string, + params: Record, + ): ResultAsync { + const responder = bucket.findLast((r) => r.route === route && matchesParams(r.paramsMatcher, params)); + if (!responder) { + throw new Error( + `FakeGithubClient: no ${kind} responder for \`${route}\` with params ${JSON.stringify( + params, + )}. Registered: ${formatList(bucket.map((r) => r.label))}`, + ); + } + return responder.outcome.kind === 'ok' ? okAsync(responder.outcome.value as T) : errAsync(responder.outcome.error); + } +} + +function matchesParams(matcher: Record, actual: Record): boolean { + for (const [key, expected] of Object.entries(matcher)) { + if (actual[key] !== expected) return false; + } + return true; +} + +function formatList(labels: readonly string[]): string { + if (labels.length === 0) return '(none)'; + return labels.map((l, i) => `\n ${i + 1}. ${l}`).join(''); +} diff --git a/src/testHelpers/FakeIo.ts b/src/testHelpers/FakeIo.ts new file mode 100644 index 0000000..620fc5c --- /dev/null +++ b/src/testHelpers/FakeIo.ts @@ -0,0 +1,11 @@ +import { BaseIo } from '../BaseIo.ts'; +import { MemoryStream } from './MemoryStream.ts'; + +export class FakeIo extends BaseIo { + declare readonly stdout: MemoryStream; + declare readonly stderr: MemoryStream; + + constructor() { + super({ stdout: new MemoryStream(), stderr: new MemoryStream() }); + } +} diff --git a/src/testHelpers/MemoryStream.ts b/src/testHelpers/MemoryStream.ts new file mode 100644 index 0000000..45cb0d3 --- /dev/null +++ b/src/testHelpers/MemoryStream.ts @@ -0,0 +1,14 @@ +import { Writable } from 'node:stream'; + +export class MemoryStream extends Writable { + private chunks: Buffer[] = []; + + override _write(chunk: Buffer | string, encoding: BufferEncoding, callback: (error?: Error | null) => void): void { + this.chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding)); + callback(); + } + + text(): string { + return Buffer.concat(this.chunks).toString('utf8'); + } +} diff --git a/src/testHelpers/createFakeContext.ts b/src/testHelpers/createFakeContext.ts new file mode 100644 index 0000000..cb68b47 --- /dev/null +++ b/src/testHelpers/createFakeContext.ts @@ -0,0 +1,42 @@ +import pino from 'pino'; +import type { Context } from '../context.ts'; +import type { Environment } from '../environment.ts'; +import type { Logger } from '../logger.ts'; +import { FakeAnalytics } from './FakeAnalytics.ts'; +import { FakeClock } from './FakeClock.ts'; +import { FakeFileSystem } from './FakeFileSystem.ts'; +import { FakeGithubClient } from './FakeGithubClient.ts'; +import { FakeIo } from './FakeIo.ts'; + +export interface FakeContextHandle { + readonly ctx: Context; + readonly io: FakeIo; + readonly logger: Logger; + readonly clock: FakeClock; + readonly fs: FakeFileSystem; + readonly githubClient: FakeGithubClient; + readonly analytics: FakeAnalytics; +} + +const defaultEnv: Environment = { + LOG_LEVEL: 'trace', + DO_NOT_TRACK: false, + CONTEXTBRIDGE_TELEMETRY_DISABLED: false, + CI: false, +}; + +export function createFakeContext(): FakeContextHandle { + const io = new FakeIo(); + const clock = new FakeClock(); + const fs = new FakeFileSystem(); + const githubClient = new FakeGithubClient(); + const analytics = new FakeAnalytics(); + + // Route fake logger output to FakeIo.stderr (raw pino JSON, no pino-pretty) so + // tests can substring-match log content via io.stderr.text(). + const logger: Logger = pino({ level: 'trace' }, io.stderr); + + const ctx: Context = { io, logger, env: defaultEnv, clock, fs, githubClient, analytics }; + + return { ctx, io, logger, clock, fs, githubClient, analytics }; +} diff --git a/src/testHelpers/index.ts b/src/testHelpers/index.ts new file mode 100644 index 0000000..1edf267 --- /dev/null +++ b/src/testHelpers/index.ts @@ -0,0 +1,2 @@ +export { FakeGithubClient } from './FakeGithubClient.ts'; +export { createFakeContext } from './createFakeContext.ts'; From 2ef4ad35ec0f8f18750886a0e28417a17e948480 Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Sun, 24 May 2026 08:48:59 -0600 Subject: [PATCH 4/7] feat: data collectors and dependency-bump heuristics Per-domain GitHub collectors plus the classification heuristics they feed. Each collector accepts its client/IO via Context and returns a Result; per-repo failures are surfaced as warnings rather than aborting the run. Collectors: - repos: org repo listing + language breakdown - contributors: active committers - branchProtection: required-checks / protection rules - dependabotConfig: presence and shape of dependabot config - dependabotPrs: Dependabot PR history - cve: security/CVE alerts - reverts: revert detection, indexed against Dependabot PRs Heuristics: - bumpType: semver bump classification + dev-dependency detection --- src/collectors/branchProtection.test.ts | 119 +++++++++++++ src/collectors/branchProtection.ts | 108 ++++++++++++ src/collectors/contributors.test.ts | 34 ++++ src/collectors/contributors.ts | 34 ++++ src/collectors/cve.test.ts | 104 ++++++++++++ src/collectors/cve.ts | 69 ++++++++ src/collectors/dependabotConfig.test.ts | 162 ++++++++++++++++++ src/collectors/dependabotConfig.ts | 108 ++++++++++++ src/collectors/dependabotPrs.test.ts | 70 ++++++++ src/collectors/dependabotPrs.ts | 215 ++++++++++++++++++++++++ src/collectors/repos.test.ts | 93 ++++++++++ src/collectors/repos.ts | 61 +++++++ src/collectors/testFactories.ts | 20 +++ src/heuristics/bumpType.test.ts | 38 +++++ src/heuristics/bumpType.ts | 32 ++++ 15 files changed, 1267 insertions(+) create mode 100644 src/collectors/branchProtection.test.ts create mode 100644 src/collectors/branchProtection.ts create mode 100644 src/collectors/contributors.test.ts create mode 100644 src/collectors/contributors.ts create mode 100644 src/collectors/cve.test.ts create mode 100644 src/collectors/cve.ts create mode 100644 src/collectors/dependabotConfig.test.ts create mode 100644 src/collectors/dependabotConfig.ts create mode 100644 src/collectors/dependabotPrs.test.ts create mode 100644 src/collectors/dependabotPrs.ts create mode 100644 src/collectors/repos.test.ts create mode 100644 src/collectors/repos.ts create mode 100644 src/collectors/testFactories.ts create mode 100644 src/heuristics/bumpType.test.ts create mode 100644 src/heuristics/bumpType.ts diff --git a/src/collectors/branchProtection.test.ts b/src/collectors/branchProtection.test.ts new file mode 100644 index 0000000..7485219 --- /dev/null +++ b/src/collectors/branchProtection.test.ts @@ -0,0 +1,119 @@ +import { expect, test } from 'bun:test'; +import { FakeGithubClient } from '../testHelpers/index.ts'; +import { getBranchProtection } from './branchProtection.ts'; + +const CLASSIC = 'GET /repos/{owner}/{repo}/branches/{branch}/protection'; +const RULES = 'GET /repos/{owner}/{repo}/rules/branches/{branch}'; + +test('builds a classic-only slice when rulesets return an empty list', async () => { + const client = new FakeGithubClient(); + client.onRequest(CLASSIC, { branch: 'main' }).resolves({ + required_pull_request_reviews: { required_approving_review_count: 2 }, + required_status_checks: { contexts: ['test'] }, + }); + client.onRequest(RULES, { branch: 'main' }).resolves([]); + + const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toMatchObject({ + hasProtection: true, + sources: ['classic'], + requiredApprovingReviewCount: 2, + requiresStatusChecks: true, + }); + } +}); + +test('builds a ruleset-only slice when classic 404s but rulesets are active', async () => { + const client = new FakeGithubClient(); + client.onRequest(CLASSIC, { branch: 'main' }).fails({ kind: 'not-found', message: 'no classic' }); + client + .onRequest(RULES, { branch: 'main' }) + .resolves([ + { type: 'pull_request', parameters: { required_approving_review_count: 1 } }, + { type: 'required_status_checks', parameters: {} }, + { type: 'deletion' }, + ]); + + const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toMatchObject({ + hasProtection: true, + sources: ['ruleset'], + requiredApprovingReviewCount: 1, + requiresStatusChecks: true, + }); + } +}); + +test('merges classic + ruleset, taking the strictest review count and OR-ing status checks', async () => { + const client = new FakeGithubClient(); + client.onRequest(CLASSIC, { branch: 'main' }).resolves({ + required_pull_request_reviews: { required_approving_review_count: 1 }, + required_status_checks: null, + }); + client.onRequest(RULES, { branch: 'main' }).resolves([ + { type: 'pull_request', parameters: { required_approving_review_count: 2 } }, + { type: 'required_status_checks', parameters: {} }, + ]); + + const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toMatchObject({ + hasProtection: true, + sources: ['classic', 'ruleset'], + requiredApprovingReviewCount: 2, + requiresStatusChecks: true, + }); + } +}); + +test('returns hasProtection: false when both classic and rulesets are absent', async () => { + const client = new FakeGithubClient(); + client.onRequest(CLASSIC, {}).fails({ kind: 'not-found', message: 'no classic' }); + client.onRequest(RULES, {}).resolves([]); + + const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toMatchObject({ + hasProtection: false, + sources: [], + requiredApprovingReviewCount: null, + requiresStatusChecks: false, + }); + } +}); + +test('treats a 404 on the rules-for-branch endpoint as no rulesets', async () => { + const client = new FakeGithubClient(); + client.onRequest(CLASSIC, {}).fails({ kind: 'not-found', message: 'no classic' }); + client.onRequest(RULES, {}).fails({ kind: 'not-found', message: 'no rules' }); + + const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); + expect(result.isOk()).toBe(true); + if (result.isOk()) expect(result.value).toMatchObject({ hasProtection: false, sources: [] }); +}); + +test('propagates non-404 classic errors so the partial-failure boundary can log it', async () => { + const client = new FakeGithubClient(); + client.onRequest(CLASSIC, {}).fails({ kind: 'http', status: 500, message: 'boom' }); + client.onRequest(RULES, {}).resolves([]); + + const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); + expect(result.isErr()).toBe(true); + if (result.isErr()) expect(result.error).toMatchObject({ kind: 'http', status: 500 }); +}); + +test('propagates non-404 ruleset errors so the partial-failure boundary can log it', async () => { + const client = new FakeGithubClient(); + client.onRequest(CLASSIC, {}).fails({ kind: 'not-found', message: 'no classic' }); + client.onRequest(RULES, {}).fails({ kind: 'http', status: 500, message: 'boom' }); + + const result = await getBranchProtection(client, { owner: 'acme', name: 'widgets' }, 'main'); + expect(result.isErr()).toBe(true); + if (result.isErr()) expect(result.error).toMatchObject({ kind: 'http', status: 500 }); +}); diff --git a/src/collectors/branchProtection.ts b/src/collectors/branchProtection.ts new file mode 100644 index 0000000..e960d55 --- /dev/null +++ b/src/collectors/branchProtection.ts @@ -0,0 +1,108 @@ +import { ResultAsync, errAsync, okAsync } from 'neverthrow'; +import type { GithubError } from '../github/errors.ts'; +import type { GithubClient } from '../github/GithubClient.ts'; +import type { BranchProtectionSlice, BranchProtectionSource, RepoRef } from '../types.ts'; + +interface ClassicProtectionResponse { + required_pull_request_reviews?: { + required_approving_review_count?: number; + }; + required_status_checks?: { + contexts?: string[]; + } | null; +} + +interface RulesetRule { + type: string; + parameters?: { + required_approving_review_count?: number; + }; +} + +interface PartialProtection { + source: BranchProtectionSource; + requiredApprovingReviewCount: number | null; + requiresStatusChecks: boolean; +} + +export function getBranchProtection( + client: GithubClient, + ref: RepoRef, + branch: string, +): ResultAsync { + return ResultAsync.combine([ + getClassicProtection(client, ref, branch), + getRulesetProtection(client, ref, branch), + ]).map(([classic, ruleset]) => merge(ref, [classic, ruleset])); +} + +function getClassicProtection( + client: GithubClient, + ref: RepoRef, + branch: string, +): ResultAsync { + return client + .request('GET /repos/{owner}/{repo}/branches/{branch}/protection', { + owner: ref.owner, + repo: ref.name, + branch, + }) + .map( + (data): PartialProtection => ({ + source: 'classic', + requiredApprovingReviewCount: data.required_pull_request_reviews?.required_approving_review_count ?? null, + requiresStatusChecks: (data.required_status_checks?.contexts?.length ?? 0) > 0, + }), + ) + .orElse((err) => { + // 404 is the documented "no classic branch protection configured". + if (err.kind === 'not-found') return okAsync(null); + return errAsync(err); + }); +} + +function getRulesetProtection( + client: GithubClient, + ref: RepoRef, + branch: string, +): ResultAsync { + // The /rules/branches/{branch} endpoint returns the effective rules applied + // to a branch from any active ruleset (repo-level or inherited). It does NOT + // include classic branch protection — that's still a separate endpoint. + return client + .request('GET /repos/{owner}/{repo}/rules/branches/{branch}', { + owner: ref.owner, + repo: ref.name, + branch, + }) + .map((rules): PartialProtection | null => { + if (rules.length === 0) return null; + const prRule = rules.find((r) => r.type === 'pull_request'); + const statusRule = rules.find((r) => r.type === 'required_status_checks'); + const reviewCount = prRule?.parameters?.required_approving_review_count; + return { + source: 'ruleset', + requiredApprovingReviewCount: typeof reviewCount === 'number' ? reviewCount : null, + requiresStatusChecks: statusRule !== undefined, + }; + }) + .orElse((err) => { + if (err.kind === 'not-found') return okAsync(null); + return errAsync(err); + }); +} + +function merge(ref: RepoRef, parts: ReadonlyArray): BranchProtectionSlice { + const active = parts.filter((p): p is PartialProtection => p !== null); + const reviewCounts = active + .map((p) => p.requiredApprovingReviewCount) + .filter((n): n is number => typeof n === 'number'); + return { + ...ref, + hasProtection: active.length > 0, + sources: active.map((p) => p.source), + // When multiple sources require reviews, the strictest one wins. + requiredApprovingReviewCount: reviewCounts.length > 0 ? Math.max(...reviewCounts) : null, + requiresStatusChecks: active.some((p) => p.requiresStatusChecks), + }; +} diff --git a/src/collectors/contributors.test.ts b/src/collectors/contributors.test.ts new file mode 100644 index 0000000..3969540 --- /dev/null +++ b/src/collectors/contributors.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from 'bun:test'; +import { FakeGithubClient } from '../testHelpers/index.ts'; +import { listActiveCommitters } from './contributors.ts'; + +test('returns unique human committers, sorted, skipping bots', async () => { + const client = new FakeGithubClient(); + client.onPaginate('GET /repos/{owner}/{repo}/commits', {}).resolves([ + { author: { login: 'alice', type: 'User' }, commit: { author: { name: 'a', date: '' } } }, + { author: { login: 'bob', type: 'User' }, commit: { author: { name: 'b', date: '' } } }, + { author: { login: 'alice', type: 'User' }, commit: { author: { name: 'a', date: '' } } }, + { author: { login: 'dependabot[bot]', type: 'Bot' }, commit: { author: { name: 'd', date: '' } } }, + { author: { login: 'renovate[bot]', type: 'User' }, commit: { author: { name: 'r', date: '' } } }, + { author: null, commit: { author: null } }, + ]); + + const result = await listActiveCommitters(client, { owner: 'acme', name: 'widgets' }, '2026-01-01T00:00:00Z'); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toMatchObject({ + owner: 'acme', + name: 'widgets', + activeHumanLogins: ['alice', 'bob'], + }); + } +}); + +test('propagates errors instead of swallowing them', async () => { + const client = new FakeGithubClient(); + client.onPaginate('GET /repos/{owner}/{repo}/commits', {}).fails({ kind: 'forbidden', message: 'no access' }); + + const result = await listActiveCommitters(client, { owner: 'acme', name: 'widgets' }, '2026-01-01T00:00:00Z'); + expect(result.isErr()).toBe(true); + if (result.isErr()) expect(result.error).toMatchObject({ kind: 'forbidden' }); +}); diff --git a/src/collectors/contributors.ts b/src/collectors/contributors.ts new file mode 100644 index 0000000..1902b4e --- /dev/null +++ b/src/collectors/contributors.ts @@ -0,0 +1,34 @@ +import type { ResultAsync } from 'neverthrow'; +import type { GithubError } from '../github/errors.ts'; +import type { GithubClient } from '../github/GithubClient.ts'; +import type { ContributorSlice, RepoRef } from '../types.ts'; + +interface ListCommitsItem { + author: { login: string; type?: string } | null; + commit: { author: { name: string; date: string } | null }; +} + +export function listActiveCommitters( + client: GithubClient, + ref: RepoRef, + windowStartIso: string, +): ResultAsync { + return client + .paginate('GET /repos/{owner}/{repo}/commits', { + owner: ref.owner, + repo: ref.name, + since: windowStartIso, + per_page: 100, + }) + .map((commits) => { + const logins = new Set(); + for (const c of commits) { + const author = c.author; + if (!author) continue; + if (author.type === 'Bot') continue; + if (author.login.endsWith('[bot]')) continue; + logins.add(author.login); + } + return { ...ref, activeHumanLogins: [...logins].sort() }; + }); +} diff --git a/src/collectors/cve.test.ts b/src/collectors/cve.test.ts new file mode 100644 index 0000000..dc26fba --- /dev/null +++ b/src/collectors/cve.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from 'bun:test'; +import { FakeGithubClient } from '../testHelpers/index.ts'; +import { getCveAlerts } from './cve.ts'; + +test('maps raw alerts to CveAlert with normalized severity', async () => { + const client = new FakeGithubClient(); + client.onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}).resolves([ + { + number: 1, + state: 'open', + created_at: '2026-03-01T00:00:00Z', + security_advisory: { summary: 'Critical RCE' }, + security_vulnerability: { + severity: 'Critical', + package: { name: 'left-pad', ecosystem: 'npm' }, + }, + }, + { + number: 2, + state: 'open', + created_at: '2026-04-01T00:00:00Z', + security_advisory: { summary: 'Moderate issue' }, + security_vulnerability: { + severity: 'moderate', + package: { name: 'lodash', ecosystem: 'npm' }, + }, + }, + ]); + + const result = await getCveAlerts(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + const slice = result.value; + expect(slice.status).toBe('ok'); + if (slice.status === 'ok') { + expect(slice.alerts).toHaveLength(2); + expect(slice.alerts[0]).toMatchObject({ severity: 'critical', packageName: 'left-pad' }); + expect(slice.alerts[1]).toMatchObject({ severity: 'medium', packageName: 'lodash' }); + } + } +}); + +test('converts a scope-missing error into the corresponding slice', async () => { + const client = new FakeGithubClient(); + client + .onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}) + .fails({ kind: 'scope-missing', required: 'security_events', message: 'missing scope' }); + + const result = await getCveAlerts(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toEqual({ + owner: 'acme', + name: 'widgets', + status: 'scope-missing', + requiredScope: 'security_events', + }); + } +}); + +test("converts a 404 into 'not-enabled'", async () => { + const client = new FakeGithubClient(); + client + .onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}) + .fails({ kind: 'not-found', message: 'not found' }); + + const result = await getCveAlerts(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) expect(result.value).toEqual({ owner: 'acme', name: 'widgets', status: 'not-enabled' }); +}); + +test("converts a 403 'Dependabot alerts are disabled' into 'not-enabled'", async () => { + const client = new FakeGithubClient(); + client.onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}).fails({ + kind: 'forbidden', + message: 'Dependabot alerts are disabled for this repository.', + }); + + const result = await getCveAlerts(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) expect(result.value).toEqual({ owner: 'acme', name: 'widgets', status: 'not-enabled' }); +}); + +test('propagates an unrelated 403 instead of swallowing it as not-enabled', async () => { + const client = new FakeGithubClient(); + client + .onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}) + .fails({ kind: 'forbidden', message: 'Must have admin rights to Repository.' }); + + const result = await getCveAlerts(client, { owner: 'acme', name: 'widgets' }); + expect(result.isErr()).toBe(true); + if (result.isErr()) expect(result.error).toMatchObject({ kind: 'forbidden' }); +}); + +test('propagates other errors instead of pretending alerts are disabled', async () => { + const client = new FakeGithubClient(); + client + .onPaginate('GET /repos/{owner}/{repo}/dependabot/alerts', {}) + .fails({ kind: 'http', status: 503, message: 'service unavailable' }); + + const result = await getCveAlerts(client, { owner: 'acme', name: 'widgets' }); + expect(result.isErr()).toBe(true); + if (result.isErr()) expect(result.error).toMatchObject({ kind: 'http', status: 503 }); +}); diff --git a/src/collectors/cve.ts b/src/collectors/cve.ts new file mode 100644 index 0000000..273553a --- /dev/null +++ b/src/collectors/cve.ts @@ -0,0 +1,69 @@ +import { type ResultAsync, errAsync, okAsync } from 'neverthrow'; +import type { GithubError } from '../github/errors.ts'; +import type { GithubClient } from '../github/GithubClient.ts'; +import type { CveAlert, CveSeverity, CveSlice, RepoRef } from '../types.ts'; + +interface RawCveAlert { + number: number; + state: string; + created_at: string; + security_advisory: { summary: string }; + security_vulnerability: { + severity: string; + package: { name: string; ecosystem: string }; + }; +} + +export function getCveAlerts(client: GithubClient, ref: RepoRef): ResultAsync { + return client + .paginate('GET /repos/{owner}/{repo}/dependabot/alerts', { + owner: ref.owner, + repo: ref.name, + state: 'open', + per_page: 100, + }) + .map((raw): CveSlice => { + const alerts: CveAlert[] = raw.map((a) => ({ + owner: ref.owner, + name: ref.name, + number: a.number, + severity: normalizeSeverity(a.security_vulnerability.severity), + createdAt: a.created_at, + packageName: a.security_vulnerability.package.name, + ecosystem: a.security_vulnerability.package.ecosystem, + summary: a.security_advisory.summary, + })); + return { owner: ref.owner, name: ref.name, status: 'ok', alerts }; + }) + .orElse((err) => { + // Token missing the security_events scope: surface to the caller so the + // report can prompt the user to refresh auth, but don't fail the run. + if (err.kind === 'scope-missing') { + return okAsync({ + owner: ref.owner, + name: ref.name, + status: 'scope-missing', + requiredScope: err.required, + }); + } + // 404 is GitHub's signal that Dependabot alerts aren't enabled on this + // repo (or it doesn't exist for this token's scope) — semantic answer. + if (err.kind === 'not-found') { + return okAsync({ owner: ref.owner, name: ref.name, status: 'not-enabled' }); + } + // GitHub also returns 403 with body "Dependabot alerts are disabled for + // this repository." when the feature is off — treat the same as 404. + if (err.kind === 'forbidden' && /alerts are disabled/i.test(err.message)) { + return okAsync({ owner: ref.owner, name: ref.name, status: 'not-enabled' }); + } + return errAsync(err); + }); +} + +function normalizeSeverity(raw: string): CveSeverity { + const v = raw.toLowerCase(); + if (v === 'critical') return 'critical'; + if (v === 'high') return 'high'; + if (v === 'medium' || v === 'moderate') return 'medium'; + return 'low'; +} diff --git a/src/collectors/dependabotConfig.test.ts b/src/collectors/dependabotConfig.test.ts new file mode 100644 index 0000000..31af4f4 --- /dev/null +++ b/src/collectors/dependabotConfig.test.ts @@ -0,0 +1,162 @@ +import { expect, test } from 'bun:test'; +import { FakeGithubClient } from '../testHelpers/index.ts'; +import { getDependabotConfig } from './dependabotConfig.ts'; + +function base64(text: string): string { + return Buffer.from(text, 'utf8').toString('base64'); +} + +test('parses ecosystems from a dependabot.yml', async () => { + const client = new FakeGithubClient(); + client.onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yml' }).resolves({ + content: base64(` +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + `), + encoding: 'base64', + }); + + const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toMatchObject({ + hasConfig: true, + ecosystems: ['github-actions', 'npm'], + updates: [ + { ecosystem: 'npm', interval: 'weekly', openPullRequestsLimit: 5, groupCount: 0, ignoreCount: 0 }, + { ecosystem: 'github-actions', interval: 'daily', openPullRequestsLimit: 5, groupCount: 0, ignoreCount: 0 }, + ], + }); + } +}); + +test('captures open-pull-requests-limit, groups, and ignore counts per entry', async () => { + const client = new FakeGithubClient(); + client.onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yml' }).resolves({ + content: base64(` +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 20 + groups: + eslint: + patterns: + - "eslint*" + react: + patterns: + - "react*" + - "react-dom" + ignore: + - dependency-name: "lodash" + - dependency-name: "express" + versions: ["4.x"] + - dependency-name: "react" +`), + encoding: 'base64', + }); + + const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value.updates).toEqual([ + { + ecosystem: 'npm', + interval: 'monthly', + openPullRequestsLimit: 20, + groupCount: 2, + ignoreCount: 3, + }, + ]); + } +}); + +test('defaults openPullRequestsLimit to 5 when not specified', async () => { + const client = new FakeGithubClient(); + client.onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yml' }).resolves({ + content: base64(`updates:\n - package-ecosystem: bundler\n schedule:\n interval: weekly\n`), + encoding: 'base64', + }); + + const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) expect(result.value.updates[0]?.openPullRequestsLimit).toBe(5); +}); + +test('falls back to dependabot.yaml when .yml is absent', async () => { + const client = new FakeGithubClient(); + client + .onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yml' }) + .fails({ kind: 'not-found', message: 'no .yml' }); + client.onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yaml' }).resolves({ + content: base64(`updates:\n - package-ecosystem: bundler\n`), + encoding: 'base64', + }); + + const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toMatchObject({ + hasConfig: true, + ecosystems: ['bundler'], + }); + } +}); + +test('returns hasConfig: false when both paths 404 but the call still succeeds', async () => { + const client = new FakeGithubClient(); + for (const path of ['.github/dependabot.yml', '.github/dependabot.yaml']) { + client + .onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path }) + .fails({ kind: 'not-found', message: 'no config' }); + } + + const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toMatchObject({ + hasConfig: false, + ecosystems: [], + updates: [], + }); + } +}); + +test('returns an empty updates list when the YAML is malformed', async () => { + const client = new FakeGithubClient(); + client.onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yml' }).resolves({ + content: base64('this: : is\n: not valid: yaml: [\n'), + encoding: 'base64', + }); + + const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toMatchObject({ + hasConfig: true, + ecosystems: [], + updates: [], + }); + } +}); + +test('propagates non-404 errors when fetching the config', async () => { + const client = new FakeGithubClient(); + client + .onRequest('GET /repos/{owner}/{repo}/contents/{path}', { path: '.github/dependabot.yml' }) + .fails({ kind: 'forbidden', message: 'no access' }); + + const result = await getDependabotConfig(client, { owner: 'acme', name: 'widgets' }); + expect(result.isErr()).toBe(true); + if (result.isErr()) expect(result.error).toMatchObject({ kind: 'forbidden' }); +}); diff --git a/src/collectors/dependabotConfig.ts b/src/collectors/dependabotConfig.ts new file mode 100644 index 0000000..a85efcb --- /dev/null +++ b/src/collectors/dependabotConfig.ts @@ -0,0 +1,108 @@ +import { Result, ResultAsync, errAsync, okAsync } from 'neverthrow'; +import type { GithubError } from '../github/errors.ts'; +import type { GithubClient } from '../github/GithubClient.ts'; +import type { DependabotConfigSlice, DependabotInterval, DependabotUpdateEntry, RepoRef } from '../types.ts'; + +interface ContentResponse { + content?: string; + encoding?: string; +} + +const CONFIG_PATHS = ['.github/dependabot.yml', '.github/dependabot.yaml']; + +const DEFAULT_OPEN_PR_LIMIT = 5; + +const safeYamlParse = Result.fromThrowable( + (text: string) => Bun.YAML.parse(text), + () => null, +); + +export function getDependabotConfig( + client: GithubClient, + ref: RepoRef, +): ResultAsync { + return fetchFirstAvailable(client, ref, CONFIG_PATHS).map((configBody): DependabotConfigSlice => { + const hasConfig = configBody !== null; + const updates = configBody === null ? [] : parseUpdates(configBody); + const ecosystems = [...new Set(updates.map((u) => u.ecosystem))].sort(); + return { ...ref, hasConfig, ecosystems, updates }; + }); +} + +function fetchFirstAvailable( + client: GithubClient, + ref: RepoRef, + paths: readonly string[], +): ResultAsync { + const [head, ...rest] = paths; + if (head === undefined) return okAsync(null); + return client + .request('GET /repos/{owner}/{repo}/contents/{path}', { + owner: ref.owner, + repo: ref.name, + path: head, + }) + .map((data) => decodeContent(data)) + .orElse((err) => { + if (err.kind === 'not-found') return fetchFirstAvailable(client, ref, rest); + return errAsync(err); + }); +} + +function decodeContent(data: ContentResponse): string | null { + if (!data.content) return null; + if (data.encoding && data.encoding !== 'base64') return null; + return Buffer.from(data.content, 'base64').toString('utf8'); +} + +function parseUpdates(yamlText: string): DependabotUpdateEntry[] { + const parsed = safeYamlParse(yamlText).unwrapOr(null); + if (!isRecord(parsed)) return []; + const rawUpdates = parsed.updates; + if (!Array.isArray(rawUpdates)) return []; + const entries: DependabotUpdateEntry[] = []; + for (const raw of rawUpdates) { + const entry = normalizeUpdate(raw); + if (entry !== null) entries.push(entry); + } + return entries; +} + +function normalizeUpdate(raw: unknown): DependabotUpdateEntry | null { + if (!isRecord(raw)) return null; + const ecosystem = typeof raw['package-ecosystem'] === 'string' ? raw['package-ecosystem'] : null; + if (ecosystem === null) return null; + return { + ecosystem, + interval: extractInterval(raw.schedule), + openPullRequestsLimit: extractOpenPrLimit(raw['open-pull-requests-limit']), + groupCount: extractGroupCount(raw.groups), + ignoreCount: extractListCount(raw.ignore), + }; +} + +function extractInterval(schedule: unknown): DependabotInterval | null { + if (!isRecord(schedule)) return null; + const value = schedule.interval; + if (value === 'daily' || value === 'weekly' || value === 'monthly') return value; + return null; +} + +function extractOpenPrLimit(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return Math.floor(value); + return DEFAULT_OPEN_PR_LIMIT; +} + +function extractGroupCount(value: unknown): number { + if (!isRecord(value)) return 0; + return Object.keys(value).length; +} + +function extractListCount(value: unknown): number { + if (!Array.isArray(value)) return 0; + return value.length; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/src/collectors/dependabotPrs.test.ts b/src/collectors/dependabotPrs.test.ts new file mode 100644 index 0000000..c7c1d8b --- /dev/null +++ b/src/collectors/dependabotPrs.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from 'bun:test'; +import { FakeGithubClient } from '../testHelpers/index.ts'; +import { listDependabotPrs } from './dependabotPrs.ts'; +import { rawPullRequest } from './testFactories.ts'; + +test('maps a single page of search results to DependabotPr', async () => { + const client = new FakeGithubClient(); + client.onGraphql('DependabotPrs').resolves({ + search: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + rawPullRequest.build({ + state: 'MERGED', + mergedAt: '2026-04-05T00:00:00Z', + mergedBy: { login: 'alice' }, + reviews: { nodes: [{ author: { login: 'bob' } }, { author: { login: 'alice' } }] }, + comments: { nodes: [{ author: { login: 'alice' } }] }, + }), + ], + }, + }); + + const result = await listDependabotPrs(client, 'acme', '2026-01-01T00:00:00Z'); + expect(result.isOk()).toBe(true); + const prs = result.unwrapOr([]); + expect(prs).toHaveLength(1); + expect(prs[0]).toMatchObject({ + owner: 'acme', + name: 'widgets', + state: 'closed', + merged: true, + mergedBy: 'alice', + reviewers: ['alice', 'bob'], + commenters: ['alice'], + }); +}); + +test('pages through results when hasNextPage is true', async () => { + const client = new FakeGithubClient(); + // First page returns cursor; second returns no more. + client + .onGraphql((_q, vars) => vars.cursor === null) + .resolves({ + search: { + pageInfo: { hasNextPage: true, endCursor: 'CURSOR_1' }, + nodes: [rawPullRequest.build({ number: 1 })], + }, + }); + client + .onGraphql((_q, vars) => vars.cursor === 'CURSOR_1') + .resolves({ + search: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [rawPullRequest.build({ number: 2 })], + }, + }); + + const result = await listDependabotPrs(client, 'acme', '2026-01-01T00:00:00Z'); + expect(result.isOk()).toBe(true); + expect(result.unwrapOr([]).map((p) => p.number)).toEqual([1, 2]); + expect(client.callsTo('graphql')).toHaveLength(2); +}); + +test('propagates errors from the GraphQL call', async () => { + const client = new FakeGithubClient(); + client.onGraphql('DependabotPrs').fails({ kind: 'forbidden', message: 'no access' }); + + const result = await listDependabotPrs(client, 'acme', '2026-01-01T00:00:00Z'); + expect(result.isErr()).toBe(true); +}); diff --git a/src/collectors/dependabotPrs.ts b/src/collectors/dependabotPrs.ts new file mode 100644 index 0000000..a5e48a6 --- /dev/null +++ b/src/collectors/dependabotPrs.ts @@ -0,0 +1,215 @@ +import { ResultAsync, okAsync } from 'neverthrow'; +import type { GithubError } from '../github/errors.ts'; +import type { GithubClient } from '../github/GithubClient.ts'; +import type { CheckSummary, DependabotPr, PrState } from '../types.ts'; + +interface GraphqlSearchResponse { + search: { + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + nodes: Array; + }; +} + +export interface RawPullRequest { + number: number; + title: string; + state: 'OPEN' | 'CLOSED' | 'MERGED'; + createdAt: string; + closedAt: string | null; + mergedAt: string | null; + url: string; + baseRefName: string; + headRefName: string; + mergedBy: { login: string } | null; + autoMergeRequest: { enabledAt: string | null } | null; + repository: { owner: { login: string }; name: string }; + reviews: { nodes: Array<{ author: { login: string } | null } | null> }; + comments: { nodes: Array<{ author: { login: string } | null } | null> }; + commits: { + nodes: Array<{ + commit: { + statusCheckRollup: { + contexts: { + nodes: Array< + | { __typename: 'CheckRun'; name: string; conclusion: string | null } + | { __typename: 'StatusContext'; context: string; state: string } + | null + >; + }; + } | null; + }; + } | null>; + }; +} + +const SEARCH_QUERY = /* GraphQL */ ` + query DependabotPrs($searchQuery: String!, $cursor: String) { + search(query: $searchQuery, type: ISSUE, first: 50, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + ... on PullRequest { + number + title + state + createdAt + closedAt + mergedAt + url + baseRefName + headRefName + mergedBy { + login + } + autoMergeRequest { + enabledAt + } + repository { + owner { + login + } + name + } + reviews(first: 50) { + nodes { + author { + login + } + } + } + comments(first: 50) { + nodes { + author { + login + } + } + } + commits(last: 1) { + nodes { + commit { + statusCheckRollup { + contexts(first: 100) { + nodes { + __typename + ... on CheckRun { + name + conclusion + } + ... on StatusContext { + context + state + } + } + } + } + } + } + } + } + } + } + } +`; + +export function listDependabotPrs( + client: GithubClient, + org: string, + windowStartIso: string, +): ResultAsync { + const searchQuery = [`is:pr`, `author:app/dependabot`, `org:${org}`, `updated:>=${windowStartIso.slice(0, 10)}`].join( + ' ', + ); + return pageThrough(client, searchQuery, null, []); +} + +function pageThrough( + client: GithubClient, + searchQuery: string, + cursor: string | null, + acc: DependabotPr[], +): ResultAsync { + return client.graphql(SEARCH_QUERY, { searchQuery, cursor }).andThen((res) => { + for (const node of res.search.nodes) { + if (node === null) continue; + acc.push(toDependabotPr(node)); + } + if (res.search.pageInfo.hasNextPage && res.search.pageInfo.endCursor) { + return pageThrough(client, searchQuery, res.search.pageInfo.endCursor, acc); + } + return okAsync(acc); + }); +} + +function toDependabotPr(raw: RawPullRequest): DependabotPr { + const state: PrState = raw.state === 'OPEN' ? 'open' : 'closed'; + const merged = raw.state === 'MERGED'; + const reviewers = uniqueLogins(raw.reviews.nodes.map((n) => n?.author?.login)); + const commenters = uniqueLogins(raw.comments.nodes.map((n) => n?.author?.login)); + return { + owner: raw.repository.owner.login, + name: raw.repository.name, + number: raw.number, + title: raw.title, + state, + merged, + createdAt: raw.createdAt, + closedAt: raw.closedAt, + mergedAt: raw.mergedAt, + mergedBy: raw.mergedBy?.login ?? null, + headRef: raw.headRefName, + baseRef: raw.baseRefName, + htmlUrl: raw.url, + reviewers, + commenters, + autoMergeEnabled: raw.autoMergeRequest !== null, + checks: summarizeChecks(raw), + }; +} + +function summarizeChecks(raw: RawPullRequest): CheckSummary { + const summary: CheckSummary = { + total: 0, + success: 0, + failure: 0, + pending: 0, + failedCheckNames: [], + }; + const commitNode = raw.commits.nodes[0]; + const rollup = commitNode?.commit.statusCheckRollup; + if (!rollup) return summary; + for (const ctx of rollup.contexts.nodes) { + if (ctx === null) continue; + summary.total += 1; + if (ctx.__typename === 'CheckRun') { + const c = (ctx.conclusion ?? '').toUpperCase(); + if (c === 'SUCCESS' || c === 'NEUTRAL' || c === 'SKIPPED') { + summary.success += 1; + } else if (c === 'FAILURE' || c === 'TIMED_OUT' || c === 'CANCELLED' || c === 'ACTION_REQUIRED') { + summary.failure += 1; + summary.failedCheckNames.push(ctx.name); + } else { + summary.pending += 1; + } + } else { + const s = ctx.state.toUpperCase(); + if (s === 'SUCCESS') summary.success += 1; + else if (s === 'FAILURE' || s === 'ERROR') { + summary.failure += 1; + summary.failedCheckNames.push(ctx.context); + } else summary.pending += 1; + } + } + return summary; +} + +function uniqueLogins(values: Array): string[] { + const seen = new Set(); + for (const v of values) { + if (!v) continue; + if (v.endsWith('[bot]')) continue; + seen.add(v); + } + return [...seen].sort(); +} diff --git a/src/collectors/repos.test.ts b/src/collectors/repos.test.ts new file mode 100644 index 0000000..bf22a2f --- /dev/null +++ b/src/collectors/repos.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from 'bun:test'; +import { FakeGithubClient } from '../testHelpers/index.ts'; +import { getRepoLanguages, listOrgRepos } from './repos.ts'; + +test('maps raw repo payloads into RepoMeta and infers visibility', async () => { + const client = new FakeGithubClient(); + client.onPaginate('GET /orgs/{org}/repos', {}).resolves([ + { + name: 'widgets', + owner: { login: 'acme' }, + private: false, + visibility: 'public', + archived: false, + default_branch: 'main', + language: 'TypeScript', + pushed_at: '2026-04-01T00:00:00Z', + security_and_analysis: { dependabot_security_updates: { status: 'enabled' } }, + }, + { + name: 'internal-tool', + owner: { login: 'acme' }, + private: true, + visibility: 'internal', + archived: true, + default_branch: 'main', + language: null, + pushed_at: null, + }, + ]); + + const result = await listOrgRepos(client, 'acme'); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + const repos = result.value; + expect(repos).toHaveLength(2); + expect(repos[0]).toMatchObject({ + owner: 'acme', + name: 'widgets', + visibility: 'public', + archived: false, + dependabotSecurityUpdates: true, + }); + expect(repos[1]).toMatchObject({ + visibility: 'internal', + archived: true, + dependabotSecurityUpdates: null, + }); + } +}); + +test('falls back to the user endpoint when the org endpoint 404s', async () => { + const client = new FakeGithubClient(); + client.onPaginate('GET /orgs/{org}/repos', {}).fails({ kind: 'not-found', message: 'no org' }); + client.onPaginate('GET /users/{username}/repos', {}).resolves([ + { + name: 'solo', + owner: { login: 'blimmer' }, + private: false, + visibility: 'public', + archived: false, + default_branch: 'main', + language: 'TypeScript', + pushed_at: '2026-04-01T00:00:00Z', + }, + ]); + + const result = await listOrgRepos(client, 'blimmer'); + expect(result.isOk()).toBe(true); + if (result.isOk()) expect(result.value[0]).toMatchObject({ owner: 'blimmer', name: 'solo' }); +}); + +test('propagates non-404 errors from listOrgRepos', async () => { + const client = new FakeGithubClient(); + client.onPaginate('GET /orgs/{org}/repos', {}).fails({ kind: 'forbidden', message: 'no access' }); + + const result = await listOrgRepos(client, 'acme'); + expect(result.isErr()).toBe(true); + if (result.isErr()) expect(result.error).toMatchObject({ kind: 'forbidden' }); +}); + +test('getRepoLanguages returns bytes keyed by language', async () => { + const client = new FakeGithubClient(); + client.onRequest('GET /repos/{owner}/{repo}/languages', {}).resolves({ TypeScript: 1000, JavaScript: 200 }); + + const result = await getRepoLanguages(client, { owner: 'acme', name: 'widgets' }); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toEqual({ + ref: { owner: 'acme', name: 'widgets' }, + bytes: { TypeScript: 1000, JavaScript: 200 }, + }); + } +}); diff --git a/src/collectors/repos.ts b/src/collectors/repos.ts new file mode 100644 index 0000000..028dd01 --- /dev/null +++ b/src/collectors/repos.ts @@ -0,0 +1,61 @@ +import { ResultAsync, errAsync } from 'neverthrow'; +import type { GithubError } from '../github/errors.ts'; +import type { GithubClient } from '../github/GithubClient.ts'; +import type { RepoMeta, RepoRef, Visibility } from '../types.ts'; + +interface RawRepo { + name: string; + owner: { login: string }; + private: boolean; + visibility?: string; + archived: boolean; + default_branch: string; + language: string | null; + pushed_at: string | null; + security_and_analysis?: { + dependabot_security_updates?: { status: 'enabled' | 'disabled' }; + } | null; +} + +export function listOrgRepos(client: GithubClient, org: string): ResultAsync { + return client + .paginate('GET /orgs/{org}/repos', { org, per_page: 100, type: 'all' }) + .orElse((err) => { + if (err.kind === 'not-found') { + return client.paginate('GET /users/{username}/repos', { + username: org, + per_page: 100, + type: 'owner', + }); + } + return errAsync(err); + }) + .map((repos) => repos.map(toRepoMeta)); +} + +export function getRepoLanguages( + client: GithubClient, + ref: RepoRef, +): ResultAsync<{ ref: RepoRef; bytes: Record }, GithubError> { + return client + .request>('GET /repos/{owner}/{repo}/languages', { + owner: ref.owner, + repo: ref.name, + }) + .map((bytes) => ({ ref, bytes })); +} + +function toRepoMeta(raw: RawRepo): RepoMeta { + const visibility: Visibility = raw.visibility === 'internal' ? 'internal' : raw.private ? 'private' : 'public'; + const securityUpdates = raw.security_and_analysis?.dependabot_security_updates?.status; + return { + owner: raw.owner.login, + name: raw.name, + visibility, + archived: raw.archived, + defaultBranch: raw.default_branch, + primaryLanguage: raw.language, + pushedAt: raw.pushed_at, + dependabotSecurityUpdates: securityUpdates === undefined ? null : securityUpdates === 'enabled', + }; +} diff --git a/src/collectors/testFactories.ts b/src/collectors/testFactories.ts new file mode 100644 index 0000000..f754acc --- /dev/null +++ b/src/collectors/testFactories.ts @@ -0,0 +1,20 @@ +import { Factory } from 'fishery'; +import type { RawPullRequest } from './dependabotPrs.ts'; + +export const rawPullRequest = Factory.define(() => ({ + number: 1, + title: 'Bump lodash from 4.17.20 to 4.17.21', + state: 'OPEN', + createdAt: '2026-04-01T00:00:00Z', + closedAt: null, + mergedAt: null, + url: 'https://github.com/acme/widgets/pull/1', + baseRefName: 'main', + headRefName: 'dependabot/npm_and_yarn/lodash-4.17.21', + mergedBy: null, + autoMergeRequest: null, + repository: { owner: { login: 'acme' }, name: 'widgets' }, + reviews: { nodes: [] }, + comments: { nodes: [] }, + commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, +})); diff --git a/src/heuristics/bumpType.test.ts b/src/heuristics/bumpType.test.ts new file mode 100644 index 0000000..343fd74 --- /dev/null +++ b/src/heuristics/bumpType.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from 'bun:test'; +import { classifyBumpType, isDevDependencyBump } from './bumpType.ts'; + +test('classifies a patch bump', () => { + expect(classifyBumpType('Bump lodash from 4.17.20 to 4.17.21')).toBe('patch'); +}); + +test('classifies a minor bump', () => { + expect(classifyBumpType('Bump lodash from 4.17.20 to 4.18.0')).toBe('minor'); +}); + +test('classifies a major bump', () => { + expect(classifyBumpType('Bump lodash from 4.17.20 to 5.0.0')).toBe('major'); +}); + +test('handles conventional commit prefix', () => { + expect(classifyBumpType('build(deps): bump react from 18.2.0 to 18.3.0')).toBe('minor'); +}); + +test('handles dev-deps conventional commit prefix', () => { + expect(classifyBumpType('build(deps-dev): bump @types/node from 18.0.0 to 18.0.1')).toBe('patch'); +}); + +test('returns other for unparseable titles', () => { + expect(classifyBumpType('Update some dependency')).toBe('other'); +}); + +test('classifies grouped Dependabot updates as grouped', () => { + expect(classifyBumpType('Bump the production-dependencies group with 5 updates')).toBe('grouped'); + expect(classifyBumpType('Bump the github-actions group across 3 directories with 6 updates')).toBe('grouped'); + expect(classifyBumpType('build(deps): bump the prod-deps group with 4 updates')).toBe('grouped'); +}); + +test('detects dev-dependency bumps via conventional commit', () => { + expect(isDevDependencyBump('build(deps-dev): bump @types/node from 18.0.0 to 18.0.1')).toBe(true); + expect(isDevDependencyBump('build(deps): bump react from 18.0.0 to 18.0.1')).toBe(false); + expect(isDevDependencyBump('Bump react from 18.0.0 to 18.0.1')).toBe(false); +}); diff --git a/src/heuristics/bumpType.ts b/src/heuristics/bumpType.ts new file mode 100644 index 0000000..11e5bf3 --- /dev/null +++ b/src/heuristics/bumpType.ts @@ -0,0 +1,32 @@ +import { Result } from 'neverthrow'; +import semver from 'semver'; + +export type BumpType = 'patch' | 'minor' | 'major' | 'grouped' | 'other'; + +const BUMP_RE = /[Bb]ump\s+\S+\s+from\s+([^\s]+)\s+to\s+([^\s]+)/; +const GROUPED_RE = /\bbump the \S+ group\b/i; + +const safeDiff = Result.fromThrowable( + (from: string | semver.SemVer, to: string | semver.SemVer) => semver.diff(from, to), + () => 'diff-failed' as const, +); + +export function classifyBumpType(prTitle: string): BumpType { + if (GROUPED_RE.test(prTitle)) return 'grouped'; + const match = BUMP_RE.exec(prTitle); + if (!match) return 'other'; + const from = semver.coerce(match[1] ?? ''); + const to = semver.coerce(match[2] ?? ''); + if (!from || !to) return 'other'; + + const diff = safeDiff(from, to).unwrapOr(null); + if (diff === null) return 'other'; + if (diff === 'major' || diff === 'premajor') return 'major'; + if (diff === 'minor' || diff === 'preminor') return 'minor'; + if (diff === 'patch' || diff === 'prepatch' || diff === 'prerelease') return 'patch'; + return 'other'; +} + +export function isDevDependencyBump(prTitle: string): boolean { + return /\(deps-dev\)/.test(prTitle); +} From 376adb8f85169e23b2dfc7253bd08daf0c8a189b Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Sun, 24 May 2026 08:49:05 -0600 Subject: [PATCH 5/7] feat: report aggregation, cost model, bundling, and HTML render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns collected data into the shippable report artifact. - aggregate.ts: folds collector output into the ReportBundle — per-repo and org-wide rollups, time series, risk/automation slices - costFormulas.ts: labor/cost model used by the savings estimates - bundle.ts: assembles report files and zips them (fflate) - html.ts: renders the single-file HTML report, inlining the built web bundle (dist/report-web) as a text import - testFactories.ts: report-layer fixture builders --- src/report/aggregate.test.ts | 280 ++++++++++++++++++++ src/report/aggregate.ts | 490 +++++++++++++++++++++++++++++++++++ src/report/bundle.test.ts | 61 +++++ src/report/bundle.ts | 87 +++++++ src/report/costFormulas.ts | 94 +++++++ src/report/embeddedShape.ts | 15 ++ src/report/html.test.ts | 79 ++++++ src/report/html.ts | 43 +++ src/report/testFactories.ts | 139 ++++++++++ 9 files changed, 1288 insertions(+) create mode 100644 src/report/aggregate.test.ts create mode 100644 src/report/aggregate.ts create mode 100644 src/report/bundle.test.ts create mode 100644 src/report/bundle.ts create mode 100644 src/report/costFormulas.ts create mode 100644 src/report/embeddedShape.ts create mode 100644 src/report/html.test.ts create mode 100644 src/report/html.ts create mode 100644 src/report/testFactories.ts diff --git a/src/report/aggregate.test.ts b/src/report/aggregate.test.ts new file mode 100644 index 0000000..c39e8c4 --- /dev/null +++ b/src/report/aggregate.test.ts @@ -0,0 +1,280 @@ +import { expect, test } from 'bun:test'; +import { + branchProtectionSlice, + collectedData, + collectionContext, + cveAlert, + cveSliceOk, + dependabotConfigSlice, + dependabotPr, + dependabotUpdateEntry, + repoMeta, +} from '../testFactories.ts'; +import { instantFromString } from '../time.ts'; +import { aggregate } from './aggregate.ts'; + +test('counts merged-in-window PRs and surfaces backlog age buckets', () => { + const data = collectedData.build({ + dependabotPrs: [ + dependabotPr.build({ + state: 'closed', + merged: true, + mergedAt: '2026-04-01T00:00:00Z', + createdAt: '2026-03-30T00:00:00Z', + }), + dependabotPr.build({ + state: 'open', + createdAt: '2025-09-01T00:00:00Z', + }), + ], + }); + + const bundle = aggregate(data); + expect(bundle.prBacklog).toMatchObject({ + openCount: 1, + mergedInWindowCount: 1, + oldestOpenDays: expect.any(Number) as number, + }); + // The 263-day-old PR should fall into the 180+ bucket. + const oldBucket = bundle.prBacklog.openAgeBuckets.find((b) => b.label === '180+ days'); + expect(oldBucket?.count).toBe(1); +}); + +test('rolls org/visibility/language counts up into orgOverview', () => { + const data = collectedData.build({ + repos: [ + repoMeta.build({ name: 'a', visibility: 'public', primaryLanguage: 'TypeScript' }), + repoMeta.build({ name: 'b', visibility: 'private', primaryLanguage: 'JavaScript' }), + repoMeta.build({ name: 'c', visibility: 'private', primaryLanguage: 'Go' }), + repoMeta.build({ name: 'd', visibility: 'internal', primaryLanguage: null, archived: true }), + ], + branchProtection: [ + branchProtectionSlice.build({ name: 'a', hasProtection: true }), + branchProtectionSlice.build({ name: 'b', hasProtection: false }), + ], + }); + + const bundle = aggregate(data); + expect(bundle.orgOverview).toMatchObject({ + repoCount: 3, + publicCount: 1, + privateCount: 2, + archivedExcluded: 1, + nodeTsRepoCount: 2, + reposWithBranchProtection: 1, + }); +}); + +test('emits a scope-missing CVE exposure when any slice signals scope-missing', () => { + const data = collectedData.build({ + cve: [{ owner: 'acme', name: 'widgets', status: 'scope-missing', requiredScope: 'security_events' }], + }); + const bundle = aggregate(data); + expect(bundle.cve).toMatchObject({ status: 'scope-missing', requiredScope: 'security_events' }); +}); + +test('counts CVE alerts by severity and surfaces oldest critical days', () => { + const data = collectedData.build({ + ctx: collectionContext.build({ now: instantFromString('2026-05-22T00:00:00Z') }), + cve: [ + cveSliceOk.build({ + alerts: [ + cveAlert.build({ severity: 'critical', createdAt: '2026-01-01T00:00:00Z' }), + cveAlert.build({ severity: 'high' }), + cveAlert.build({ severity: 'high' }), + ], + }), + ], + }); + const bundle = aggregate(data); + expect(bundle.cve.status).toBe('ok'); + if (bundle.cve.status === 'ok') { + expect(bundle.cve.bySeverity).toEqual({ critical: 1, high: 2, medium: 0, low: 0 }); + expect(bundle.cve.oldestCriticalDays).toBeGreaterThanOrEqual(141); + } +}); + +test('aggregates failing check names across open PRs, sorted by frequency', () => { + const prA = dependabotPr.build({ + state: 'open', + checks: { + total: 2, + success: 0, + failure: 2, + pending: 0, + failedCheckNames: ['test (unit)', 'lint'], + }, + }); + const prB = dependabotPr.build({ + state: 'open', + checks: { + total: 2, + success: 1, + failure: 1, + pending: 0, + failedCheckNames: ['test (unit)'], + }, + }); + const prC = dependabotPr.build({ + state: 'open', + checks: { + total: 3, + success: 2, + failure: 1, + pending: 0, + failedCheckNames: ['typecheck', 'typecheck'], + }, + }); + + const bundle = aggregate(collectedData.build({ dependabotPrs: [prA, prB, prC] })); + expect(bundle.prBacklog.failingCheckBreakdown).toEqual([ + { checkName: 'test (unit)', failingPrCount: 2 }, + { checkName: 'lint', failingPrCount: 1 }, + { checkName: 'typecheck', failingPrCount: 1 }, + ]); +}); + +test('builds a cost estimate matrix with rate columns and time-per-PR rows', () => { + const data = collectedData.build({ + dependabotPrs: Array.from({ length: 100 }, (_, i) => + dependabotPr.build({ + number: i + 1, + state: 'closed', + merged: true, + mergedAt: '2026-04-01T00:00:00Z', + createdAt: '2026-03-30T00:00:00Z', + }), + ), + }); + const bundle = aggregate(data); + expect(bundle.costEstimate.mergedInWindow).toBe(100); + expect(bundle.costEstimate.hourlyRateUsd).toBe(150); + expect(bundle.costEstimate.minutesPerPr).toBe(5); + // 100 PRs × 5 min × $150/hr / 60 = $1250 in window + expect(bundle.costEstimate.windowCostUsd).toBe(1250); + // ~$423/month over 90 days (window × 30.44/90) + expect(bundle.costEstimate.monthlyCostUsd).toBeGreaterThan(400); + expect(bundle.costEstimate.monthlyCostUsd).toBeLessThan(450); + expect(bundle.costEstimate.annualCostUsd).toBe(bundle.costEstimate.monthlyCostUsd * 12); + expect(bundle.costEstimate.savingsScenarios.map((s) => s.autoMergeRate)).toEqual([0.5, 0.6, 0.7, 0.8]); + expect(bundle.costEstimate.savingsScenarios[0]?.annualSavingsUsd).toBe( + (bundle.costEstimate.savingsScenarios[0]?.monthlySavingsUsd ?? 0) * 12, + ); +}); + +test('topMergers excludes bot logins and surfaces per-person weekly cost', () => { + const data = collectedData.build({ + dependabotPrs: [ + dependabotPr.build({ number: 1, merged: true, mergedAt: '2026-04-01T00:00:00Z', mergedBy: 'alice' }), + dependabotPr.build({ number: 2, merged: true, mergedAt: '2026-04-02T00:00:00Z', mergedBy: 'alice' }), + dependabotPr.build({ number: 3, merged: true, mergedAt: '2026-04-03T00:00:00Z', mergedBy: 'github-actions' }), + dependabotPr.build({ number: 4, merged: true, mergedAt: '2026-04-04T00:00:00Z', mergedBy: 'dependabot' }), + dependabotPr.build({ number: 5, merged: true, mergedAt: '2026-04-05T00:00:00Z', mergedBy: 'renovate-bot' }), + dependabotPr.build({ number: 6, merged: true, mergedAt: '2026-04-06T00:00:00Z', mergedBy: 'bob' }), + ], + }); + const bundle = aggregate(data); + expect(bundle.people.topMergers.map((m) => m.login)).toEqual(['alice', 'bob']); + const alice = bundle.people.topMergers.find((m) => m.login === 'alice'); + expect(alice?.count).toBe(2); + expect(alice?.windowCostUsd).toBeGreaterThan(0); + expect(alice?.annualCostUsd).toBeGreaterThan(alice?.windowCostUsd ?? 0); +}); + +test('cadenceBreakdown counts update entries by schedule interval', () => { + const data = collectedData.build({ + dependabotConfig: [ + dependabotConfigSlice.build({ + name: 'a', + updates: [ + dependabotUpdateEntry.build({ ecosystem: 'npm', interval: 'daily' }), + dependabotUpdateEntry.build({ ecosystem: 'github-actions', interval: 'weekly' }), + ], + }), + dependabotConfigSlice.build({ + name: 'b', + updates: [dependabotUpdateEntry.build({ ecosystem: 'npm', interval: 'weekly' })], + }), + dependabotConfigSlice.build({ + name: 'c', + updates: [dependabotUpdateEntry.build({ ecosystem: 'docker', interval: null })], + }), + ], + }); + const bundle = aggregate(data); + expect(bundle.dependabotCoverage.cadenceBreakdown).toEqual([ + { interval: 'daily', entryCount: 1 }, + { interval: 'weekly', entryCount: 2 }, + { interval: 'unspecified', entryCount: 1 }, + ]); +}); + +test('reposUsingGroups and reposWithIgnoreRules count repos with any non-zero entry', () => { + const data = collectedData.build({ + dependabotConfig: [ + dependabotConfigSlice.build({ + name: 'a', + updates: [dependabotUpdateEntry.build({ groupCount: 2, ignoreCount: 0 })], + }), + dependabotConfigSlice.build({ + name: 'b', + updates: [ + dependabotUpdateEntry.build({ groupCount: 0, ignoreCount: 0 }), + dependabotUpdateEntry.build({ groupCount: 0, ignoreCount: 3 }), + ], + }), + dependabotConfigSlice.build({ + name: 'c', + updates: [dependabotUpdateEntry.build({ groupCount: 1, ignoreCount: 1 })], + }), + ], + }); + const bundle = aggregate(data); + expect(bundle.dependabotCoverage).toMatchObject({ + reposUsingGroups: 2, + reposWithIgnoreRules: 2, + }); +}); + +test("reposAtPrCap uses each repo's effective cap from sum of entry limits", () => { + const fiveOpenPrs = (name: string) => + Array.from({ length: 5 }, (_, i) => + dependabotPr.build({ name, number: i + 1, state: 'open', createdAt: '2026-05-01T00:00:00Z' }), + ); + const data = collectedData.build({ + dependabotConfig: [ + // repo with a single default-limit entry — cap is 5; 5 open trips it + dependabotConfigSlice.build({ + name: 'capped', + updates: [dependabotUpdateEntry.build({ ecosystem: 'npm', openPullRequestsLimit: 5 })], + }), + // repo with a raised limit — cap is 20; 5 open should NOT trip it + dependabotConfigSlice.build({ + name: 'raised', + updates: [dependabotUpdateEntry.build({ ecosystem: 'npm', openPullRequestsLimit: 20 })], + }), + ], + dependabotPrs: [...fiveOpenPrs('capped'), ...fiveOpenPrs('raised')], + }); + const bundle = aggregate(data); + expect(bundle.stalledSignals.reposAtPrCap).toEqual([{ repo: 'acme/capped', openPrs: 5 }]); +}); + +test('dependabotCoverage reflects only the live (non-archived) repos', () => { + const data = collectedData.build({ + repos: [ + repoMeta.build({ name: 'a', archived: false, dependabotSecurityUpdates: true }), + repoMeta.build({ name: 'b', archived: false, dependabotSecurityUpdates: false }), + repoMeta.build({ name: 'c', archived: true, dependabotSecurityUpdates: true }), + ], + dependabotConfig: [ + dependabotConfigSlice.build({ name: 'a', hasConfig: true, ecosystems: ['npm'] }), + dependabotConfigSlice.build({ name: 'b', hasConfig: false, ecosystems: [] }), + ], + }); + const bundle = aggregate(data); + expect(bundle.dependabotCoverage).toMatchObject({ + reposWithConfig: 1, + reposWithSecurityUpdates: 1, + }); +}); diff --git a/src/report/aggregate.ts b/src/report/aggregate.ts new file mode 100644 index 0000000..8f7ed08 --- /dev/null +++ b/src/report/aggregate.ts @@ -0,0 +1,490 @@ +import { classifyBumpType, isDevDependencyBump } from '../heuristics/bumpType.ts'; +import { type Instant, Temporal, instantFromString } from '../time.ts'; +import type { + CollectedData, + CveAlert, + CveSeverity, + DependabotConfigSlice, + DependabotPr, + LanguageBytes, +} from '../types.ts'; +import { + ASSUMED_HOURLY_RATE_USD, + ASSUMED_MIN_PER_PR, + ASSUMED_MIN_PER_REVIEW, + deriveCostEstimate, + derivePersonCosts, +} from './costFormulas.ts'; + +export interface ReportBundle { + meta: ReportMeta; + orgOverview: OrgOverview; + dependabotCoverage: DependabotCoverage; + prBacklog: PrBacklog; + stalledSignals: StalledSignals; + people: People; + costEstimate: CostEstimate; + cve: CveExposure; +} + +export interface ReportMeta { + org: string; + windowDays: number; + generatedAt: Instant; + totalReposScanned: number; +} + +export interface OrgOverview { + repoCount: number; + publicCount: number; + privateCount: number; + internalCount: number; + archivedExcluded: number; + topLanguages: Array<{ language: string; bytes: number; percentage: number }>; + nodeTsRepoCount: number; + nodeTsRepoPercentage: number; + activeHumanCommitters: number; + reposWithBranchProtection: number; +} + +export type CadenceLabel = 'daily' | 'weekly' | 'monthly' | 'unspecified'; + +export interface DependabotCoverage { + reposWithConfig: number; + reposWithConfigPercentage: number; + reposWithSecurityUpdates: number; + reposWithSecurityUpdatesPercentage: number; + ecosystemBreakdown: Array<{ ecosystem: string; repoCount: number }>; + cadenceBreakdown: Array<{ interval: CadenceLabel; entryCount: number }>; + reposUsingGroups: number; + reposWithIgnoreRules: number; +} + +export interface PrBacklog { + openCount: number; + closedInWindowCount: number; + mergedInWindowCount: number; + openAgeBuckets: Array<{ label: string; count: number }>; + oldestOpenDays: number | null; + bumpTypeSplit: Array<{ bumpType: string; count: number; percentage: number }>; + devOnlyShare: { count: number; percentage: number }; + ciStatusMix: { green: number; failing: number; pending: number }; + failingCheckBreakdown: Array<{ checkName: string; failingPrCount: number }>; + timeToMergeP50Days: number | null; + timeToMergeP90Days: number | null; +} + +export interface StalledSignals { + reposAtPrCap: Array<{ repo: string; openPrs: number }>; + reposWithConfigButNoRecentPrs: string[]; +} + +export interface People { + topMergers: Array<{ login: string; count: number; windowCostUsd: number; annualCostUsd: number }>; + topReviewers: Array<{ login: string; count: number; windowCostUsd: number; annualCostUsd: number }>; + topCommenters: Array<{ login: string; count: number }>; +} + +export interface CostEstimate { + mergedInWindow: number; + openCount: number; + windowDays: number; + hourlyRateUsd: number; + minutesPerPr: number; + windowCostUsd: number; + monthlyCostUsd: number; + annualCostUsd: number; + savingsScenarios: Array<{ autoMergeRate: number; monthlySavingsUsd: number; annualSavingsUsd: number }>; +} + +export interface CveExposure { + status: 'ok' | 'scope-missing' | 'no-data'; + requiredScope?: string; + totalOpenAlerts: number; + bySeverity: Record; + topReposBySeverity: Array<{ repo: string; critical: number; high: number; medium: number; low: number }>; + oldestCriticalDays: number | null; + oldestHighDays: number | null; + reposWithSecurityAlertsDisabled: string[]; +} + +const DEFAULT_PR_CAP = 5; + +export function aggregate(data: CollectedData): ReportBundle { + const now = data.ctx.now; + const windowStart = data.ctx.windowStart; + const meta: ReportMeta = { + org: data.ctx.org, + windowDays: data.ctx.windowDays, + generatedAt: now, + totalReposScanned: data.repos.length, + }; + + const orgOverview = buildOrgOverview(data); + const dependabotCoverage = buildDependabotCoverage(data); + const prBacklog = buildPrBacklog(data, now, windowStart); + const stalledSignals = buildStalledSignals(data, windowStart); + const people = buildPeople(data, data.ctx.windowDays); + const costEstimate = buildCostEstimate(prBacklog, data.ctx.windowDays); + const cve = buildCveExposure(data, now); + + return { + meta, + orgOverview, + dependabotCoverage, + prBacklog, + stalledSignals, + people, + costEstimate, + cve, + }; +} + +function buildOrgOverview(data: CollectedData): OrgOverview { + const repos = data.repos.filter((r) => !r.archived); + const archivedExcluded = data.repos.length - repos.length; + const publicCount = repos.filter((r) => r.visibility === 'public').length; + const privateCount = repos.filter((r) => r.visibility === 'private').length; + const internalCount = repos.filter((r) => r.visibility === 'internal').length; + + const aggregateBytes: LanguageBytes = {}; + for (const lang of data.languages) { + for (const [name, bytes] of Object.entries(lang.bytes)) { + aggregateBytes[name] = (aggregateBytes[name] ?? 0) + bytes; + } + } + const totalBytes = Object.values(aggregateBytes).reduce((a, b) => a + b, 0); + const topLanguages = Object.entries(aggregateBytes) + .map(([language, bytes]) => ({ + language, + bytes, + percentage: totalBytes > 0 ? round1((bytes / totalBytes) * 100) : 0, + })) + .sort((a, b) => b.bytes - a.bytes) + .slice(0, 10); + + const nodeTsRepoCount = repos.filter( + (r) => r.primaryLanguage === 'TypeScript' || r.primaryLanguage === 'JavaScript', + ).length; + + const allCommitters = new Set(); + for (const slice of data.contributors) { + for (const login of slice.activeHumanLogins) allCommitters.add(login); + } + + const reposWithBranchProtection = data.branchProtection.filter((b) => b.hasProtection).length; + + return { + repoCount: repos.length, + publicCount, + privateCount, + internalCount, + archivedExcluded, + topLanguages, + nodeTsRepoCount, + nodeTsRepoPercentage: pct(nodeTsRepoCount, repos.length), + activeHumanCommitters: allCommitters.size, + reposWithBranchProtection, + }; +} + +function buildDependabotCoverage(data: CollectedData): DependabotCoverage { + const liveRepos = data.repos.filter((r) => !r.archived); + const reposWithConfig = data.dependabotConfig.filter((c) => c.hasConfig).length; + const reposWithSecurity = liveRepos.filter((r) => r.dependabotSecurityUpdates === true).length; + + const ecoCounts = new Map(); + for (const cfg of data.dependabotConfig) { + for (const eco of cfg.ecosystems) { + ecoCounts.set(eco, (ecoCounts.get(eco) ?? 0) + 1); + } + } + const ecosystemBreakdown = [...ecoCounts.entries()] + .map(([ecosystem, repoCount]) => ({ ecosystem, repoCount })) + .sort((a, b) => b.repoCount - a.repoCount); + + const cadenceCounts: Record = { daily: 0, weekly: 0, monthly: 0, unspecified: 0 }; + let reposUsingGroups = 0; + let reposWithIgnoreRules = 0; + for (const cfg of data.dependabotConfig) { + for (const update of cfg.updates) { + cadenceCounts[update.interval ?? 'unspecified'] += 1; + } + if (cfg.updates.some((u) => u.groupCount > 0)) reposUsingGroups += 1; + if (cfg.updates.some((u) => u.ignoreCount > 0)) reposWithIgnoreRules += 1; + } + const cadenceOrder: CadenceLabel[] = ['daily', 'weekly', 'monthly', 'unspecified']; + const cadenceBreakdown = cadenceOrder + .map((interval) => ({ interval, entryCount: cadenceCounts[interval] })) + .filter((c) => c.entryCount > 0); + + return { + reposWithConfig, + reposWithConfigPercentage: pct(reposWithConfig, liveRepos.length), + reposWithSecurityUpdates: reposWithSecurity, + reposWithSecurityUpdatesPercentage: pct(reposWithSecurity, liveRepos.length), + ecosystemBreakdown, + cadenceBreakdown, + reposUsingGroups, + reposWithIgnoreRules, + }; +} + +function buildPrBacklog(data: CollectedData, now: Instant, windowStart: Instant): PrBacklog { + const prs = data.dependabotPrs; + const openPrs = prs.filter((p) => p.state === 'open'); + const mergedInWindow = prs.filter((p) => p.merged && p.mergedAt && isAtOrAfter(p.mergedAt, windowStart)); + const closedNotMergedInWindow = prs.filter((p) => !p.merged && p.closedAt && isAtOrAfter(p.closedAt, windowStart)); + + const buckets = [ + { label: '0–30 days', min: 0, max: 30 }, + { label: '30–60 days', min: 30, max: 60 }, + { label: '60–90 days', min: 60, max: 90 }, + { label: '90–180 days', min: 90, max: 180 }, + { label: '180+ days', min: 180, max: Number.POSITIVE_INFINITY }, + ]; + const openAgeBuckets = buckets.map((b) => ({ + label: b.label, + count: openPrs.filter((p) => { + const age = daysBetween(now, instantFromString(p.createdAt)); + return age >= b.min && age < b.max; + }).length, + })); + + const oldestOpenDays = + openPrs.length === 0 ? null : Math.max(...openPrs.map((p) => daysBetween(now, instantFromString(p.createdAt)))); + + const bumpCounts = new Map(); + for (const pr of prs) { + const t = classifyBumpType(pr.title); + bumpCounts.set(t, (bumpCounts.get(t) ?? 0) + 1); + } + const bumpTotal = [...bumpCounts.values()].reduce((a, b) => a + b, 0); + const bumpTypeSplit = [...bumpCounts.entries()] + .map(([bumpType, count]) => ({ bumpType, count, percentage: pct(count, bumpTotal) })) + .sort((a, b) => b.count - a.count); + + const devOnlyCount = prs.filter((p) => isDevDependencyBump(p.title)).length; + const devOnlyShare = { count: devOnlyCount, percentage: pct(devOnlyCount, prs.length) }; + + let green = 0; + let failing = 0; + let pending = 0; + const failingPrCountByCheck = new Map(); + for (const pr of openPrs) { + if (pr.checks.total === 0) { + pending += 1; + continue; + } + if (pr.checks.failure > 0) { + failing += 1; + const uniqueNames = new Set(pr.checks.failedCheckNames); + for (const name of uniqueNames) { + failingPrCountByCheck.set(name, (failingPrCountByCheck.get(name) ?? 0) + 1); + } + } else if (pr.checks.pending > 0) { + pending += 1; + } else { + green += 1; + } + } + const failingCheckBreakdown = [...failingPrCountByCheck.entries()] + .map(([checkName, failingPrCount]) => ({ checkName, failingPrCount })) + .sort((a, b) => b.failingPrCount - a.failingPrCount || a.checkName.localeCompare(b.checkName)); + + const ttMergeDays: number[] = mergedInWindow + .filter((p) => p.mergedAt !== null) + .map((p) => daysBetween(instantFromString(p.mergedAt as string), instantFromString(p.createdAt))); + ttMergeDays.sort((a, b) => a - b); + const timeToMergeP50Days = percentile(ttMergeDays, 50); + const timeToMergeP90Days = percentile(ttMergeDays, 90); + + return { + openCount: openPrs.length, + closedInWindowCount: closedNotMergedInWindow.length, + mergedInWindowCount: mergedInWindow.length, + openAgeBuckets, + oldestOpenDays, + bumpTypeSplit, + devOnlyShare, + ciStatusMix: { green, failing, pending }, + failingCheckBreakdown, + timeToMergeP50Days, + timeToMergeP90Days, + }; +} + +function buildStalledSignals(data: CollectedData, windowStart: Instant): StalledSignals { + const openByRepo = new Map(); + for (const pr of data.dependabotPrs) { + if (pr.state !== 'open') continue; + const key = `${pr.owner}/${pr.name}`; + const list = openByRepo.get(key) ?? []; + list.push(pr); + openByRepo.set(key, list); + } + const configByRepo = new Map(); + for (const cfg of data.dependabotConfig) { + configByRepo.set(`${cfg.owner}/${cfg.name}`, cfg); + } + const reposAtPrCap = [...openByRepo.entries()] + .filter(([repo, list]) => list.length >= effectivePrCap(configByRepo.get(repo))) + .map(([repo, list]) => ({ repo, openPrs: list.length })) + .sort((a, b) => b.openPrs - a.openPrs); + + const repoToRecentPrs = new Map(); + for (const pr of data.dependabotPrs) { + if (!isAtOrAfter(pr.createdAt, windowStart)) continue; + const key = `${pr.owner}/${pr.name}`; + repoToRecentPrs.set(key, (repoToRecentPrs.get(key) ?? 0) + 1); + } + const reposWithConfigButNoRecentPrs = data.dependabotConfig + .filter((c) => c.hasConfig) + .filter((c) => (repoToRecentPrs.get(`${c.owner}/${c.name}`) ?? 0) === 0) + .map((c) => `${c.owner}/${c.name}`) + .sort(); + + return { + reposAtPrCap, + reposWithConfigButNoRecentPrs, + }; +} + +function buildPeople(data: CollectedData, windowDays: number): People { + const mergerCounts = new Map(); + const reviewerCounts = new Map(); + const commenterCounts = new Map(); + for (const pr of data.dependabotPrs) { + if (pr.mergedBy && !isBotLogin(pr.mergedBy)) { + mergerCounts.set(pr.mergedBy, (mergerCounts.get(pr.mergedBy) ?? 0) + 1); + } + for (const r of pr.reviewers) if (!isBotLogin(r)) reviewerCounts.set(r, (reviewerCounts.get(r) ?? 0) + 1); + for (const c of pr.commenters) if (!isBotLogin(c)) commenterCounts.set(c, (commenterCounts.get(c) ?? 0) + 1); + } + const top = (m: Map) => + [...m.entries()] + .map(([login, count]) => ({ login, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + + const topMergers = derivePersonCosts(top(mergerCounts), windowDays, ASSUMED_MIN_PER_PR, ASSUMED_HOURLY_RATE_USD); + const topReviewers = derivePersonCosts( + top(reviewerCounts), + windowDays, + ASSUMED_MIN_PER_REVIEW, + ASSUMED_HOURLY_RATE_USD, + ); + + return { + topMergers, + topReviewers, + topCommenters: top(commenterCounts), + }; +} + +function buildCostEstimate(prBacklog: PrBacklog, windowDays: number): CostEstimate { + const merged = prBacklog.mergedInWindowCount; + const derived = deriveCostEstimate(merged, windowDays, { + hourlyRateUsd: ASSUMED_HOURLY_RATE_USD, + minutesPerPr: ASSUMED_MIN_PER_PR, + }); + return { + mergedInWindow: merged, + openCount: prBacklog.openCount, + windowDays, + hourlyRateUsd: ASSUMED_HOURLY_RATE_USD, + minutesPerPr: ASSUMED_MIN_PER_PR, + ...derived, + }; +} + +const BOT_LOGIN_RE = /(\[bot\]$|^dependabot$|^github-actions$|-bot$|^copilot$|^renovate$)/i; + +export function isBotLogin(login: string): boolean { + return BOT_LOGIN_RE.test(login); +} + +function buildCveExposure(data: CollectedData, now: Instant): CveExposure { + const scopeMissing = data.cve.find((s) => s.status === 'scope-missing'); + if (scopeMissing && scopeMissing.status === 'scope-missing') { + return { + status: 'scope-missing', + requiredScope: scopeMissing.requiredScope, + totalOpenAlerts: 0, + bySeverity: { critical: 0, high: 0, medium: 0, low: 0 }, + topReposBySeverity: [], + oldestCriticalDays: null, + oldestHighDays: null, + reposWithSecurityAlertsDisabled: [], + }; + } + + const okSlices = data.cve.filter((s) => s.status === 'ok'); + const disabledRepos = data.cve.filter((s) => s.status === 'not-enabled').map((s) => `${s.owner}/${s.name}`); + const allAlerts: CveAlert[] = okSlices.flatMap((s) => (s.status === 'ok' ? s.alerts : [])); + const bySeverity: Record = { critical: 0, high: 0, medium: 0, low: 0 }; + for (const a of allAlerts) bySeverity[a.severity] += 1; + + const byRepo = new Map>(); + for (const a of allAlerts) { + const key = `${a.owner}/${a.name}`; + let rec = byRepo.get(key); + if (!rec) { + rec = { critical: 0, high: 0, medium: 0, low: 0 }; + byRepo.set(key, rec); + } + rec[a.severity] += 1; + } + const topReposBySeverity = [...byRepo.entries()] + .map(([repo, counts]) => ({ repo, ...counts })) + .sort((a, b) => severityScore(b) - severityScore(a)) + .slice(0, 5); + + const oldest = (sev: CveSeverity): number | null => { + const filtered = allAlerts.filter((a) => a.severity === sev); + if (filtered.length === 0) return null; + return Math.max(...filtered.map((a) => daysBetween(now, instantFromString(a.createdAt)))); + }; + + return { + status: 'ok', + totalOpenAlerts: allAlerts.length, + bySeverity, + topReposBySeverity, + oldestCriticalDays: oldest('critical'), + oldestHighDays: oldest('high'), + reposWithSecurityAlertsDisabled: disabledRepos, + }; +} + +function severityScore(rec: { critical: number; high: number; medium: number; low: number }): number { + return rec.critical * 1000 + rec.high * 100 + rec.medium * 10 + rec.low; +} + +function effectivePrCap(config: DependabotConfigSlice | undefined): number { + if (!config || config.updates.length === 0) return DEFAULT_PR_CAP; + return config.updates.reduce((sum, u) => sum + u.openPullRequestsLimit, 0); +} + +function pct(numerator: number, denominator: number): number { + if (denominator === 0) return 0; + return round1((numerator / denominator) * 100); +} + +function round1(n: number): number { + return Math.round(n * 10) / 10; +} + +function daysBetween(later: Instant, earlier: Instant): number { + return Math.max(0, Math.floor((later.epochMilliseconds - earlier.epochMilliseconds) / 86_400_000)); +} + +function isAtOrAfter(iso: string, target: Instant): boolean { + return Temporal.Instant.compare(instantFromString(iso), target) >= 0; +} + +function percentile(sorted: readonly number[], p: number): number | null { + if (sorted.length === 0) return null; + const rank = Math.ceil((p / 100) * sorted.length) - 1; + return sorted[Math.max(0, Math.min(rank, sorted.length - 1))] ?? null; +} diff --git a/src/report/bundle.test.ts b/src/report/bundle.test.ts new file mode 100644 index 0000000..2964de9 --- /dev/null +++ b/src/report/bundle.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from 'bun:test'; +import { strFromU8, unzipSync } from 'fflate'; +import { collectedData, dependabotPr } from '../testFactories.ts'; +import { aggregate } from './aggregate.ts'; +import { type BundleMeta, buildBundleFiles, zipBundleFiles } from './bundle.ts'; + +const meta: BundleMeta = { + cliVersion: '0.0.1', + generatedAt: '2026-05-22T00:00:00Z', + target: 'acme', + windowDays: 90, + windowStart: '2026-02-21T00:00:00Z', + options: { include: null, exclude: [] }, + counts: { reposTotal: 1, reposIncluded: 1, dependabotPrs: 1, warnings: 0 }, +}; + +test('buildBundleFiles emits one JSON file per slice plus the html report and README', () => { + const collected = collectedData.build({ dependabotPrs: [dependabotPr.build()] }); + const aggregated = aggregate(collected); + const files = buildBundleFiles({ meta, collected, aggregated, reportHtml: 'hi' }); + + expect(Object.keys(files).sort()).toEqual( + [ + 'README.txt', + 'data/aggregated.json', + 'data/branch-protection.json', + 'data/contributors.json', + 'data/cve.json', + 'data/dependabot-config.json', + 'data/dependabot-prs.json', + 'data/languages.json', + 'data/meta.json', + 'data/repos.json', + 'data/warnings.json', + 'patchwave-report.html', + ].sort(), + ); + expect(files['patchwave-report.html']).toBe('hi'); + expect(files['README.txt']).toContain('patchwave-analysis bundle'); +}); + +test('data JSON files are pretty-printed and slice-shaped', () => { + const collected = collectedData.build({ dependabotPrs: [dependabotPr.build({ number: 42 })] }); + const aggregated = aggregate(collected); + const files = buildBundleFiles({ meta, collected, aggregated, reportHtml: '' }); + + const repos = JSON.parse(files['data/repos.json'] as string) as unknown; + expect(Array.isArray(repos)).toBe(true); + + const prs = JSON.parse(files['data/dependabot-prs.json'] as string) as Array<{ number: number }>; + expect(prs[0]?.number).toBe(42); + + expect(files['data/aggregated.json']).toContain(' '); // indented +}); + +test('zipBundleFiles round-trips through unzipSync', () => { + const zip = zipBundleFiles({ 'patchwave-report.html': '', 'data/meta.json': '{}\n' }); + const entries = unzipSync(zip); + expect(strFromU8(entries['patchwave-report.html'] as Uint8Array)).toBe(''); + expect(strFromU8(entries['data/meta.json'] as Uint8Array)).toBe('{}\n'); +}); diff --git a/src/report/bundle.ts b/src/report/bundle.ts new file mode 100644 index 0000000..b64cdd7 --- /dev/null +++ b/src/report/bundle.ts @@ -0,0 +1,87 @@ +import { strToU8, zipSync } from 'fflate'; +import type { CollectedData } from '../types.ts'; +import type { ReportBundle } from './aggregate.ts'; + +export interface BundleMeta { + cliVersion: string; + generatedAt: string; + target: string; + windowDays: number; + windowStart: string; + options: { + include: string[] | null; + exclude: string[]; + }; + counts: { + reposTotal: number; + reposIncluded: number; + dependabotPrs: number; + warnings: number; + }; +} + +export interface BundleInputs { + meta: BundleMeta; + collected: CollectedData; + aggregated: ReportBundle; + reportHtml: string; +} + +export function buildBundleFiles(inputs: BundleInputs): Record { + const { meta, collected, aggregated, reportHtml } = inputs; + return { + 'patchwave-report.html': reportHtml, + 'README.txt': sharebackReadme(meta), + 'data/meta.json': stringify(meta), + 'data/repos.json': stringify(collected.repos), + 'data/languages.json': stringify(collected.languages), + 'data/dependabot-config.json': stringify(collected.dependabotConfig), + 'data/dependabot-prs.json': stringify(collected.dependabotPrs), + 'data/cve.json': stringify(collected.cve), + 'data/branch-protection.json': stringify(collected.branchProtection), + 'data/contributors.json': stringify(collected.contributors), + 'data/warnings.json': stringify(collected.errors), + 'data/aggregated.json': stringify(aggregated), + }; +} + +export function zipBundleFiles(files: Record): Uint8Array { + const entries: Record = {}; + for (const [path, contents] of Object.entries(files)) { + entries[path] = strToU8(contents); + } + return zipSync(entries); +} + +function stringify(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function sharebackReadme(meta: BundleMeta): string { + return [ + `patchwave-analysis bundle`, + ``, + `Generated: ${meta.generatedAt}`, + `Target: ${meta.target}`, + `Window: ${meta.windowDays} days (since ${meta.windowStart})`, + `CLI: v${meta.cliVersion}`, + ``, + `Contents:`, + ` patchwave-report.html — interactive report (open in any browser)`, + ` data/meta.json — run metadata`, + ` data/aggregated.json — rolled-up metrics that drive the report`, + ` data/repos.json — repo metadata`, + ` data/languages.json — per-repo language byte counts`, + ` data/dependabot-config.json — per-repo Dependabot config + ecosystems`, + ` data/dependabot-prs.json — Dependabot PRs in the window (state, checks, reviewers)`, + ` data/cve.json — Dependabot security alert slices`, + ` data/branch-protection.json — default-branch protection slices`, + ` data/contributors.json — active human committers per repo`, + ` data/warnings.json — per-collector warnings suppressed during the crawl`, + ``, + `Sharing this zip with contextbridge gives us the same view the report does plus the raw`, + `numbers behind every metric. No tokens, secrets, or file contents leave your machine`, + `unless you choose to share this archive.`, + ``, + ].join('\n'); +} diff --git a/src/report/costFormulas.ts b/src/report/costFormulas.ts new file mode 100644 index 0000000..225a0af --- /dev/null +++ b/src/report/costFormulas.ts @@ -0,0 +1,94 @@ +// Pure cost math shared between the server-side aggregator and the client-side +// React app. No Temporal, no Bun globals, no React — keep this module dependency-free +// so it bundles cleanly into both targets. + +export const ASSUMED_HOURLY_RATE_USD = 150; +export const ASSUMED_MIN_PER_PR = 5; +export const ASSUMED_MIN_PER_REVIEW = 3; + +export const AUTO_MERGE_SCENARIO_RATES = [0.5, 0.6, 0.7, 0.8] as const; + +const DAYS_PER_MONTH = 365 / 12; + +export function windowCostFor(count: number, minutesPerAction: number, hourlyRateUsd: number): number { + return Math.round(((count * minutesPerAction) / 60) * hourlyRateUsd); +} + +export function monthlyFromWindow(windowCostUsd: number, windowDays: number): number { + return Math.round((windowCostUsd * DAYS_PER_MONTH) / Math.max(1, windowDays)); +} + +export function annualizeWindow(windowCostUsd: number, windowDays: number): number { + return Math.round((windowCostUsd * 365) / Math.max(1, windowDays)); +} + +export interface SavingsScenario { + autoMergeRate: number; + monthlySavingsUsd: number; + annualSavingsUsd: number; +} + +export interface CostAssumptions { + hourlyRateUsd: number; + minutesPerPr: number; +} + +export interface DerivedCostEstimate { + windowCostUsd: number; + monthlyCostUsd: number; + annualCostUsd: number; + savingsScenarios: SavingsScenario[]; +} + +export interface CountedPerson { + login: string; + count: number; +} + +export interface DerivedPersonCost extends CountedPerson { + windowCostUsd: number; + annualCostUsd: number; +} + +export function savingsScenariosFor(monthlyCostUsd: number): SavingsScenario[] { + return AUTO_MERGE_SCENARIO_RATES.map((autoMergeRate) => { + const monthlySavingsUsd = Math.round(monthlyCostUsd * autoMergeRate); + return { + autoMergeRate, + monthlySavingsUsd, + annualSavingsUsd: monthlySavingsUsd * 12, + }; + }); +} + +export function deriveCostEstimate( + count: number, + windowDays: number, + assumptions: CostAssumptions, +): DerivedCostEstimate { + const windowCostUsd = windowCostFor(count, assumptions.minutesPerPr, assumptions.hourlyRateUsd); + const monthlyCostUsd = monthlyFromWindow(windowCostUsd, windowDays); + return { + windowCostUsd, + monthlyCostUsd, + annualCostUsd: monthlyCostUsd * 12, + savingsScenarios: savingsScenariosFor(monthlyCostUsd), + }; +} + +export function derivePersonCosts( + people: readonly CountedPerson[], + windowDays: number, + minutesPerAction: number, + hourlyRateUsd: number, +): DerivedPersonCost[] { + return people.map(({ login, count }) => { + const windowCostUsd = windowCostFor(count, minutesPerAction, hourlyRateUsd); + return { + login, + count, + windowCostUsd, + annualCostUsd: annualizeWindow(windowCostUsd, windowDays), + }; + }); +} diff --git a/src/report/embeddedShape.ts b/src/report/embeddedShape.ts new file mode 100644 index 0000000..2cfdbf6 --- /dev/null +++ b/src/report/embeddedShape.ts @@ -0,0 +1,15 @@ +import type { ReportBundle, ReportMeta } from './aggregate.ts'; + +export interface EmbeddedReportData extends Omit { + meta: Omit & { generatedAt: string }; +} + +// The web report consumes the bundle with `generatedAt` serialized to a string. +// This module is intentionally free of runtime imports (types only) so it is safe +// to pull into the browser bundle without dragging in html.ts's embedded template. +export function toEmbeddedShape(bundle: ReportBundle): EmbeddedReportData { + return { + ...bundle, + meta: { ...bundle.meta, generatedAt: bundle.meta.generatedAt.toString() }, + }; +} diff --git a/src/report/html.test.ts b/src/report/html.test.ts new file mode 100644 index 0000000..f455423 --- /dev/null +++ b/src/report/html.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test'; +import { renderHtmlFrom, toEmbeddedShape } from './html.ts'; +import { reportBundle, reportMeta } from './testFactories.ts'; + +const STUB_TEMPLATE = + ''; + +const LINE_SEP = String.fromCharCode(0x2028); +const PARA_SEP = String.fromCharCode(0x2029); + +describe('toEmbeddedShape', () => { + test('converts Instant generatedAt to an ISO string', () => { + const bundle = reportBundle.build(); + const shape = toEmbeddedShape(bundle); + expect(typeof shape.meta.generatedAt).toBe('string'); + expect(shape.meta.generatedAt).toBe(bundle.meta.generatedAt.toString()); + }); + + test('preserves all other slices verbatim', () => { + const bundle = reportBundle.build(); + const shape = toEmbeddedShape(bundle); + expect(shape.orgOverview).toEqual(bundle.orgOverview); + expect(shape.dependabotCoverage).toEqual(bundle.dependabotCoverage); + expect(shape.prBacklog).toEqual(bundle.prBacklog); + expect(shape.stalledSignals).toEqual(bundle.stalledSignals); + expect(shape.people).toEqual(bundle.people); + expect(shape.costEstimate).toEqual(bundle.costEstimate); + expect(shape.cve).toEqual(bundle.cve); + }); +}); + +describe('renderHtmlFrom', () => { + test('substitutes the placeholder and embeds parseable JSON', () => { + const bundle = reportBundle.build(); + const result = renderHtmlFrom(STUB_TEMPLATE, bundle); + expect(result.isOk()).toBe(true); + const rendered = result.unwrapOr(''); + + expect(rendered).not.toContain('__PATCHWAVE_DATA__'); + expect(rendered).toContain(' sequences inside the embedded JSON', () => { + const bundle = reportBundle.build({ + meta: reportMeta.build({ org: 'malicious' }), + }); + const result = renderHtmlFrom(STUB_TEMPLATE, bundle); + expect(result.isOk()).toBe(true); + const rendered = result.unwrapOr(''); + const scriptCloses = rendered.match(/<\/script>/gi) ?? []; + expect(scriptCloses).toHaveLength(1); + expect(rendered).toContain('<\\/script>'); + }); + + test('escapes