From d2d8e9c73b0c764ba1d9626e8212d6eb82d1bd3f Mon Sep 17 00:00:00 2001 From: Taylor Kimmett Date: Tue, 25 Aug 2026 15:49:15 -0400 Subject: [PATCH 01/98] chore: refresh bun.lock integrity hash Co-Authored-By: Claude Opus 5 --- bun.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bun.lock b/bun.lock index b21a627..5e67b9a 100644 --- a/bun.lock +++ b/bun.lock @@ -72,7 +72,7 @@ "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], - "@electron/node-gyp": ["@electron/node-gyp@github:electron/node-gyp#06b29aa", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^8.1.0", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.2.1", "nopt": "^6.0.0", "proc-log": "^2.0.1", "semver": "^7.3.5", "tar": "^6.2.1", "which": "^2.0.2" }, "bin": "./bin/node-gyp.js" }, "electron-node-gyp-06b29aa"], + "@electron/node-gyp": ["@electron/node-gyp@github:electron/node-gyp#06b29aa", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^8.1.0", "graceful-fs": "^4.2.6", "make-fetch-happen": "^10.2.1", "nopt": "^6.0.0", "proc-log": "^2.0.1", "semver": "^7.3.5", "tar": "^6.2.1", "which": "^2.0.2" }, "bin": "./bin/node-gyp.js" }, "electron-node-gyp-06b29aa", "sha512-UJwi6aXMAiUaOvqPHVlMtCOLRa1QAU2SqYD9H07KHpN+I2mBoFuxP1HnUOkt86+j+/o/XyHpM7D33JFFQi/jfA=="], "@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="], From 3e1e0d2a18427046d551003c9d494c89dfcd06cb Mon Sep 17 00:00:00 2001 From: Taylor Kimmett Date: Tue, 25 Aug 2026 15:49:15 -0400 Subject: [PATCH 02/98] docs: add semantic review spec Co-Authored-By: Claude Opus 5 --- docs/semantic-review.md | 204 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 docs/semantic-review.md diff --git a/docs/semantic-review.md b/docs/semantic-review.md new file mode 100644 index 0000000..95e1551 --- /dev/null +++ b/docs/semantic-review.md @@ -0,0 +1,204 @@ +# Semantic Review + +On-demand, AI-assisted reorganization of a PR from a flat file list into a guided, +dependency-ordered walkthrough — analogous to CodeRabbit's "Change Stack" — plus a +zero-AI **hide test files** toggle to cut initial cognitive load on large PRs. + +Status: **spec / not implemented** + +## Motivation + +Reviewers experience a PR as an alphabetical file list and must mentally reconstruct +the author's narrative before evaluating correctness. On large (often AI-generated) +PRs, that reconstruction is the bottleneck. Pulldash already has the fast, +keyboard-driven review shell; this adds the semantic layer on top. + +## Concepts + +- **Cohort** — an independent, logically related group of change *hunks* + ("the auth refactor", "the new endpoint", "config plumbing"). Grouping is + **hunk-level**: one file's hunks can belong to different cohorts. +- **Layer** — an ordered reading step within a cohort. Layers are dependency-ordered: + foundational changes (types, data shapes, contracts) before consumers/call sites, + then tests. Each layer anchors to one or more **ranges**. +- **Range** — a contiguous span of changed lines in one file (`file`, `side`, + `startLine`, `endLine`) with a plain-language summary. +- **Diagram** — an optional Mermaid diagram (sequence / state / ER) attached to a + layer, generated only when the change warrants a visual. + +## User experience + +### Hide test files (independent of AI) + +- Toggle in the file tree header (near the existing "hide viewed" control): + **Hide tests**. +- Classification is heuristic path matching (`*_test.*`, `*.test.*`, `*.spec.*`, + `__tests__/`, `__mocks__/`, `testdata/`, `test/`, `tests/`, `spec/`, + `*_snapshot*`, `.snap`), implemented as a pure function with unit tests. +- Hidden test files are removed from the tree, `j`/`k` navigation, and search + results, with a count indicator ("12 test files hidden"); toggling back is one + click / keypress. Preference persists globally (localStorage), not per PR. + +### Semantic review mode + +- A **Semantic review** button appears in the PR header when at least one local + provider is available (see Availability). Clicking it: + 1. Checks the disk cache for `(repo, pr, headSha)`. Cache hit → open the mode + instantly. + 2. Otherwise starts an analysis job on the local server and streams progress + ("collecting diff", "provider running", elapsed time). Analysis of a large PR + can take minutes; the job continues if the user navigates away. +- Semantic mode is a **view mode of the existing PR review page**, not a separate + UI. It reuses the diff renderer, comment flows, virtualization, and keyboard model. + - **Left panel**: cohort → layer tree (replaces the file tree while in the mode; + a toggle switches back to Files view at any time). + - **Center**: the existing diff view scoped to the current layer — only hunks + intersecting the layer's ranges are shown, grouped under their file headers. + Commenting, line selection, and expansion work exactly as in file view. + - **Layer summary**: the layer title, summary, and optional diagram render above + the scoped diff. + - **Keyboard**: `j`/`k` move between layers (matching file navigation in file + view); arrows move between lines as today. +- **Reviewed tracking**: layers can be marked reviewed (`v`, same as files). + A file whose every range is inside reviewed layers is marked viewed in the + normal file view (and synced to GitHub's viewed state as today). +- **Staleness**: results are keyed to the head SHA. If the PR receives new commits, + the mode shows an "out of date — re-analyze" banner; the stale result remains + readable. + +### Failure behavior + +Invalid provider output, provider crash, or timeout → a clear error state with a +retry button. Never a broken half-view. Raw provider output is kept on disk next to +the cache entry for debugging. + +## Architecture + +``` +Browser (PRReviewStore) Local server (Hono, node or electron) +──────────────────────── ───────────────────────────────────── +POST /api/semantic/analyze ────────► job runner ──► provider (pluggable) + { owner, repo, number, │ ├─ claude (Agent SDK) + headSha, prMeta, diff } │ └─ codex (codex exec) +GET /api/semantic/jobs/:id (SSE) ◄─── progress + result +GET /api/semantic/result/... ◄── disk cache ~/.pulldash/semantic/ +GET /api/semantic/providers ◄── availability detection +``` + +- The **browser supplies the input** (PR title/description + per-file patches it + already fetched from GitHub). The local server never holds GitHub credentials. +- Endpoints live in the shared Hono `api` app (`src/api/`), so dev/CLI and Electron + get them for free. On the hosted (Vercel) deployment, `/api/semantic/providers` + returns an empty list and the browser hides the feature. + +### Provider interface + +```ts +interface SemanticProvider { + id: string; // "claude" | "codex" | ... + displayName: string; + available(): Promise; // auth/CLI detection, cheap + analyze( + input: AnalysisInput, + onProgress: (msg: string) => void, + signal: AbortSignal, + ): Promise; +} + +interface AnalysisInput { + owner: string; repo: string; number: number; headSha: string; + title: string; body: string; + files: { filename: string; status: string; patch?: string }[]; +} +``` + +- **claude**: `@anthropic-ai/claude-agent-sdk` `query()` — rides the user's + Claude subscription auth (no API key handling in pulldash). +- **codex**: spawns `codex exec` — rides the user's ChatGPT subscription. +- Both receive the same prompt contract and must emit JSON conforming to the + schema below; output is validated before acceptance and the provider is asked to + self-correct once on validation failure. +- `AnalysisInput` is deliberately abstract so a future **checkout-based** provider + (agent explores a local clone for deeper dependency inference) can slot in + without changing the schema or UI. +- If more than one provider is available, the analyze button offers a picker; + the last choice is remembered. + +### Output schema + +```ts +interface SemanticReview { + version: 1; + provider: string; + headSha: string; + generatedAt: string; // ISO 8601 + overview: string; // markdown, 2–5 sentences + cohorts: Cohort[]; +} +interface Cohort { + id: string; title: string; summary: string; + layers: Layer[]; +} +interface Layer { + id: string; title: string; summary: string; // markdown + diagram?: { kind: "sequence" | "state" | "er" | "flow"; mermaid: string }; + ranges: Range[]; +} +interface Range { + file: string; + side: "new" | "old"; // "old" only for pure deletions + startLine: number; endLine: number; + summary?: string; +} +``` + +**Coverage invariant**: every hunk in the diff must intersect at least one range. +The validator computes uncovered hunks; any remainder is placed in an automatic +**"Uncovered changes"** cohort rather than silently dropped — the reviewer must be +able to trust that semantic mode shows the whole PR. Ranges that don't match any +actual hunk are pruned with a warning. + +### Caching + +- Disk: `~/.pulldash/semantic/----/.json` + (+ `.raw.txt` for the unvalidated provider output). +- Keyed by head SHA, so historical revisions accumulate naturally — enabling a + future "what changed since I last reviewed" snapshot feature. +- No TTL; entries are immutable per SHA. A `Re-analyze` action overwrites. + +### Prompt contract (summary) + +The provider prompt instructs the agent to: +1. Read PR title/body and the full unified diff. +2. Partition all hunks into 2–7 independent cohorts by intent. +3. Order layers within each cohort foundation-first (contracts → consumers → tests). +4. Anchor every layer to exact file/line ranges from the diff (new side line + numbers), covering every hunk exactly once. +5. Write range/layer summaries in plain language explaining *why*, not restating + the diff. +6. Emit a Mermaid diagram only when the layer introduces an API interaction, + state machine, or schema relationship. +7. Output only JSON matching the schema. + +Very large diffs that exceed the provider's practical context are handled v1 by +truncating context intelligently (patch bodies of lockfiles/generated files elided +first, listed by name only); a map-reduce strategy (per-chunk grouping, then merge) +is a noted future improvement. + +## Non-goals (v1) + +- Checkout/repo-context analysis (interface keeps the door open). +- Cross-revision diffs of semantic results ("what changed since snapshot N"). +- Sharing/exporting semantic reviews. +- Semantic review on the hosted pulldash.com (requires a local agent by design). +- Author-defined manual grouping. + +## Performance notes + +Per AGENTS.md, performance is P1: +- Analysis runs entirely out-of-process (local server + external agent); the main + thread only receives SSE progress strings and one JSON payload. +- Semantic mode reuses the existing parsed-diff cache, worker highlighting, and + virtualized rendering; scoping to ranges is a filter over already-parsed hunks. +- Mermaid rendering is lazy-loaded (dynamic import) only when a layer with a + diagram is opened, keeping it out of the main bundle. From 90478bbd4a3bba13ae736fe53d904d658d6e1f82 Mon Sep 17 00:00:00 2001 From: Taylor Kimmett Date: Tue, 25 Aug 2026 15:53:55 -0400 Subject: [PATCH 03/98] feat: add hide-test-files toggle to file panel Heuristic path-based classification (src/browser/lib/test-file.ts) with a persisted global preference. Hidden test files are removed from the file tree, j/k navigation, and the command palette, with a count indicator. Part of the semantic review work (docs/semantic-review.md), but useful standalone for cutting cognitive load on large PRs. Co-Authored-By: Claude Opus 5 --- docs/semantic-review.md | 37 ++++++++++----- src/browser/components/command-palette.tsx | 12 ++++- src/browser/components/pr-review.tsx | 50 +++++++++++++++++++- src/browser/contexts/pr-review/index.tsx | 37 +++++++++++++++ src/browser/lib/test-file.test.ts | 52 ++++++++++++++++++++ src/browser/lib/test-file.ts | 55 ++++++++++++++++++++++ 6 files changed, 228 insertions(+), 15 deletions(-) create mode 100644 src/browser/lib/test-file.test.ts create mode 100644 src/browser/lib/test-file.ts diff --git a/docs/semantic-review.md b/docs/semantic-review.md index 95e1551..e225127 100644 --- a/docs/semantic-review.md +++ b/docs/semantic-review.md @@ -15,7 +15,7 @@ keyboard-driven review shell; this adds the semantic layer on top. ## Concepts -- **Cohort** — an independent, logically related group of change *hunks* +- **Cohort** — an independent, logically related group of change _hunks_ ("the auth refactor", "the new endpoint", "config plumbing"). Grouping is **hunk-level**: one file's hunks can belong to different cohorts. - **Layer** — an ordered reading step within a cohort. Layers are dependency-ordered: @@ -95,19 +95,23 @@ GET /api/semantic/providers ◄── availability detection ```ts interface SemanticProvider { - id: string; // "claude" | "codex" | ... + id: string; // "claude" | "codex" | ... displayName: string; - available(): Promise; // auth/CLI detection, cheap + available(): Promise; // auth/CLI detection, cheap analyze( input: AnalysisInput, onProgress: (msg: string) => void, - signal: AbortSignal, + signal: AbortSignal ): Promise; } interface AnalysisInput { - owner: string; repo: string; number: number; headSha: string; - title: string; body: string; + owner: string; + repo: string; + number: number; + headSha: string; + title: string; + body: string; files: { filename: string; status: string; patch?: string }[]; } ``` @@ -131,23 +135,28 @@ interface SemanticReview { version: 1; provider: string; headSha: string; - generatedAt: string; // ISO 8601 - overview: string; // markdown, 2–5 sentences + generatedAt: string; // ISO 8601 + overview: string; // markdown, 2–5 sentences cohorts: Cohort[]; } interface Cohort { - id: string; title: string; summary: string; + id: string; + title: string; + summary: string; layers: Layer[]; } interface Layer { - id: string; title: string; summary: string; // markdown + id: string; + title: string; + summary: string; // markdown diagram?: { kind: "sequence" | "state" | "er" | "flow"; mermaid: string }; ranges: Range[]; } interface Range { file: string; - side: "new" | "old"; // "old" only for pure deletions - startLine: number; endLine: number; + side: "new" | "old"; // "old" only for pure deletions + startLine: number; + endLine: number; summary?: string; } ``` @@ -169,12 +178,13 @@ actual hunk are pruned with a warning. ### Prompt contract (summary) The provider prompt instructs the agent to: + 1. Read PR title/body and the full unified diff. 2. Partition all hunks into 2–7 independent cohorts by intent. 3. Order layers within each cohort foundation-first (contracts → consumers → tests). 4. Anchor every layer to exact file/line ranges from the diff (new side line numbers), covering every hunk exactly once. -5. Write range/layer summaries in plain language explaining *why*, not restating +5. Write range/layer summaries in plain language explaining _why_, not restating the diff. 6. Emit a Mermaid diagram only when the layer introduces an API interaction, state machine, or schema relationship. @@ -196,6 +206,7 @@ is a noted future improvement. ## Performance notes Per AGENTS.md, performance is P1: + - Analysis runs entirely out-of-process (local server + external agent); the main thread only receives SSE progress strings and one JSON payload. - Semantic mode reuses the existing parsed-diff cache, worker highlighting, and diff --git a/src/browser/components/command-palette.tsx b/src/browser/components/command-palette.tsx index fc16e95..1f7746d 100644 --- a/src/browser/components/command-palette.tsx +++ b/src/browser/components/command-palette.tsx @@ -16,6 +16,7 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { useLocation } from "react-router-dom"; import { cn } from "../cn"; import { usePRReviewSelector, usePRReviewStore } from "../contexts/pr-review"; +import { isTestFile } from "@/browser/lib/test-file"; import { Keycap, KeycapGroup } from "../ui/keycap"; import type { PullRequestFile } from "@/api/types"; @@ -196,8 +197,17 @@ export const CommandPalette = memo(function CommandPalette({ const inputRef = useRef(null); const listRef = useRef(null); const store = usePRReviewStore(); - const files = usePRReviewSelector((s) => s.files); + const allFiles = usePRReviewSelector((s) => s.files); const viewedFiles = usePRReviewSelector((s) => s.viewedFiles); + const hideTestFiles = usePRReviewSelector((s) => s.hideTestFiles); + + const files = useMemo( + () => + hideTestFiles + ? allFiles.filter((f) => !isTestFile(f.filename)) + : allFiles, + [allFiles, hideTestFiles] + ); // Defer the search query so typing stays responsive const deferredSearch = useDeferredValue(search); diff --git a/src/browser/components/pr-review.tsx b/src/browser/components/pr-review.tsx index d8fcc90..ee46640 100644 --- a/src/browser/components/pr-review.tsx +++ b/src/browser/components/pr-review.tsx @@ -33,6 +33,8 @@ import { ExternalLink, BookOpen, Smile, + FlaskConical, + FlaskConicalOff, } from "lucide-react"; import type { Reaction, ReactionContent } from "../contexts/github"; import { Skeleton } from "../ui/skeleton"; @@ -46,6 +48,7 @@ import { import { cn } from "../cn"; import { PRHeader } from "./pr-header"; import { FileTree } from "./file-tree"; +import { isTestFile } from "@/browser/lib/test-file"; import { FileHeader } from "./file-header"; import type { PullRequest, PullRequestFile, ReviewComment } from "@/api/types"; import { @@ -471,8 +474,21 @@ const FilePanel = memo(function FilePanel({ const selectedFiles = usePRReviewSelector((s) => s.selectedFiles); const viewedFiles = usePRReviewSelector((s) => s.viewedFiles); const hideViewed = usePRReviewSelector((s) => s.hideViewed); + const hideTestFiles = usePRReviewSelector((s) => s.hideTestFiles); const showOverview = usePRReviewSelector((s) => s.showOverview); + const testFileCount = useMemo( + () => files.reduce((n, f) => n + (isTestFile(f.filename) ? 1 : 0), 0), + [files] + ); + const visibleFiles = useMemo( + () => + hideTestFiles && testFileCount > 0 + ? files.filter((f) => !isTestFile(f.filename)) + : files, + [files, hideTestFiles, testFileCount] + ); + const commentCounts = useCommentCountsByFile(); const pendingCommentCounts = usePendingCommentCountsByFile(); const { copyDiff, copyFile, copyMainVersion } = useFileCopyActions(); @@ -561,13 +577,45 @@ const FilePanel = memo(function FilePanel({ {hideViewed ? "Show viewed files" : "Hide viewed files"} + {testFileCount > 0 && ( + + + + + + {hideTestFiles + ? `Show ${testFileCount} test file${testFileCount === 1 ? "" : "s"}` + : `Hide ${testFileCount} test file${testFileCount === 1 ? "" : "s"}`} + + + )} + {hideTestFiles && testFileCount > 0 && ( +
+ {testFileCount} test file{testFileCount === 1 ? "" : "s"} hidden +
+ )} +
; hideViewed: boolean; + hideTestFiles: boolean; // Diffs loadedDiffs: Record; @@ -267,6 +269,22 @@ function setStoredDiffViewMode(mode: DiffViewMode): void { } catch {} } +// Global storage key for hiding test files (user preference, not per-PR) +const HIDE_TEST_FILES_KEY = "pulldash_hide_test_files"; + +function getStoredHideTestFiles(): boolean { + try { + return localStorage.getItem(HIDE_TEST_FILES_KEY) === "true"; + } catch {} + return false; +} + +function setStoredHideTestFiles(hide: boolean): void { + try { + localStorage.setItem(HIDE_TEST_FILES_KEY, String(hide)); + } catch {} +} + export class PRReviewStore { private state: PRReviewState; private listeners = new Set(); @@ -362,6 +380,7 @@ export class PRReviewStore { overviewScrollTarget: null, viewedFiles, hideViewed: true, + hideTestFiles: getStoredHideTestFiles(), diffViewMode, loadedDiffs: {}, loadingFiles: new Set(), @@ -528,6 +547,10 @@ export class PRReviewStore { } }; + // Whether a file is hidden from the tree and skipped during navigation. + private isFileHidden = (filename: string): boolean => + this.state.hideTestFiles && isTestFile(filename); + navigateToNextUnviewedFile = () => { const { files, selectedFile, viewedFiles } = this.state; const currentIdx = selectedFile @@ -537,6 +560,7 @@ export class PRReviewStore { // Search forward then wrap for (let i = 0; i < files.length; i++) { const idx = (currentIdx + 1 + i) % files.length; + if (this.isFileHidden(files[idx].filename)) continue; if (!viewedFiles.has(files[idx].filename)) { this.selectFile(files[idx].filename); return; @@ -553,6 +577,7 @@ export class PRReviewStore { // Search backward then wrap for (let i = 0; i < files.length; i++) { const idx = (currentIdx - 1 - i + files.length) % files.length; + if (this.isFileHidden(files[idx].filename)) continue; if (!viewedFiles.has(files[idx].filename)) { this.selectFile(files[idx].filename); return; @@ -656,6 +681,18 @@ export class PRReviewStore { this.set({ hideViewed: !this.state.hideViewed }); }; + toggleHideTestFiles = () => { + const hideTestFiles = !this.state.hideTestFiles; + setStoredHideTestFiles(hideTestFiles); + // If the currently selected file just became hidden, deselect it so the + // reviewer isn't left on a file that no longer exists in the tree. + const { selectedFile } = this.state; + this.set({ hideTestFiles }); + if (hideTestFiles && selectedFile && isTestFile(selectedFile)) { + this.selectOverview(); + } + }; + // --------------------------------------------------------------------------- // Diff View Mode Actions // --------------------------------------------------------------------------- diff --git a/src/browser/lib/test-file.test.ts b/src/browser/lib/test-file.test.ts new file mode 100644 index 0000000..53ee374 --- /dev/null +++ b/src/browser/lib/test-file.test.ts @@ -0,0 +1,52 @@ +import { test, expect } from "bun:test"; +import { isTestFile, countTestFiles } from "./test-file"; + +test("test-file: matches common test naming conventions", () => { + const testPaths = [ + "src/browser/lib/test-file.test.ts", + "src/components/button.spec.tsx", + "pkg/server/server_test.go", + "src/__tests__/util.ts", + "src/__mocks__/fs.ts", + "src/__snapshots__/app.tsx.snap", + "app/components/foo.test.js.snap", + "testdata/golden.json", + "test/integration/api.ts", + "tests/unit/parse.py", + "spec/models/user_spec.rb", + "e2e/login.ts", + "src/main/java/FooTest.java", + "src/FooSpec.kt", + "tests/test_parser.py", + "test_utils.py", + "conftest.py", + "cypress/fixtures/user.json", + ]; + for (const p of testPaths) { + expect(isTestFile(p), p).toBe(true); + } +}); + +test("test-file: does not match production code", () => { + const prodPaths = [ + "src/browser/lib/test-file.ts", // implementation, not test + "src/api/api.ts", + "src/contest/rules.ts", // "contest" contains "test" as substring + "src/latest/index.ts", + "src/testing-library-wrapper.ts", // no recognized convention + "docs/testing.md", + "protest/march.ts", + "src/attest.go", + "testimony.py", // not test_*.py + "src/spectrum.ts", + ]; + for (const p of prodPaths) { + expect(isTestFile(p), p).toBe(false); + } +}); + +test("test-file: countTestFiles", () => { + expect( + countTestFiles(["a_test.go", "a.go", "b.test.ts", "docs/readme.md"]) + ).toBe(2); +}); diff --git a/src/browser/lib/test-file.ts b/src/browser/lib/test-file.ts new file mode 100644 index 0000000..997b03e --- /dev/null +++ b/src/browser/lib/test-file.ts @@ -0,0 +1,55 @@ +/** + * Heuristic classification of test/spec files by path. + * + * Used by the "hide test files" toggle to reduce cognitive load when + * reviewing large PRs. Intentionally conservative: only well-known test + * naming conventions match, so production code is never hidden. + */ + +// Directory names that indicate everything inside is test-related. +const TEST_DIRS = new Set([ + "__tests__", + "__mocks__", + "__snapshots__", + "testdata", + "test", + "tests", + "spec", + "specs", + "e2e", + "fixtures", +]); + +// Filename patterns for test files across common ecosystems. +const TEST_FILE_RE = + /(\.(test|spec)\.[^./]+|_test\.[^./]+|Test\.(java|kt|cs|scala|groovy)|Spec\.(java|kt|cs|scala|groovy)|\.snap)$/; + +// Python convention: test_*.py +const PYTHON_TEST_RE = /^test_[^/]*\.py$/; + +// conftest.py is pytest plumbing. +const PYTEST_CONFTEST = "conftest.py"; + +export function isTestFile(path: string): boolean { + const segments = path.split("/"); + const basename = segments[segments.length - 1]!; + + for (let i = 0; i < segments.length - 1; i++) { + if (TEST_DIRS.has(segments[i]!)) return true; + } + + return ( + TEST_FILE_RE.test(basename) || + PYTHON_TEST_RE.test(basename) || + basename === PYTEST_CONFTEST + ); +} + +/** Count how many of the given filenames are test files. */ +export function countTestFiles(filenames: readonly string[]): number { + let count = 0; + for (const f of filenames) { + if (isTestFile(f)) count++; + } + return count; +} From 91457472fcbe6c8ced513916dfd5a22dd2f8dfbd Mon Sep 17 00:00:00 2001 From: Taylor Kimmett Date: Tue, 25 Aug 2026 15:56:17 -0400 Subject: [PATCH 04/98] feat: add semantic review data model, validation, and coverage reconciliation Pure data layer for semantic review (docs/semantic-review.md): - schema.ts: SemanticReview types + structural validator that collects all errors for provider self-correction - patch.ts: minimal GitHub patch-string hunk parser for coverage math - coverage.ts: enforces the coverage invariant - prunes ranges matching no real hunk and sweeps uncovered hunks into a synthetic cohort Co-Authored-By: Claude Opus 5 --- src/semantic/coverage.test.ts | 138 ++++++++++++++++++++++ src/semantic/coverage.ts | 163 ++++++++++++++++++++++++++ src/semantic/patch.test.ts | 59 ++++++++++ src/semantic/patch.ts | 83 ++++++++++++++ src/semantic/schema.test.ts | 104 +++++++++++++++++ src/semantic/schema.ts | 210 ++++++++++++++++++++++++++++++++++ 6 files changed, 757 insertions(+) create mode 100644 src/semantic/coverage.test.ts create mode 100644 src/semantic/coverage.ts create mode 100644 src/semantic/patch.test.ts create mode 100644 src/semantic/patch.ts create mode 100644 src/semantic/schema.test.ts create mode 100644 src/semantic/schema.ts diff --git a/src/semantic/coverage.test.ts b/src/semantic/coverage.test.ts new file mode 100644 index 0000000..b462a90 --- /dev/null +++ b/src/semantic/coverage.test.ts @@ -0,0 +1,138 @@ +import { test, expect } from "bun:test"; +import { reconcileCoverage, UNCOVERED_COHORT_ID } from "./coverage"; +import type { SemanticReview } from "./schema"; + +const FILES = [ + { + filename: "src/a.ts", + patch: `@@ -1,4 +1,6 @@ + context ++added one ++added two + context + context + context +@@ -30,3 +32,4 @@ + context ++added three + context + context +`, + }, + { + filename: "src/b.ts", + patch: `@@ -5,3 +5,3 @@ + context +-old line ++new line + context +`, + }, + { filename: "image.png" }, // binary: no patch +]; + +function review( + ranges: { + file: string; + side: "new" | "old"; + startLine: number; + endLine: number; + }[] +): SemanticReview { + return { + version: 1, + provider: "test", + headSha: "sha", + generatedAt: "2026-08-25T00:00:00Z", + overview: "o", + cohorts: [ + { + id: "c1", + title: "t", + summary: "s", + layers: [{ id: "l1", title: "t", summary: "s", ranges }], + }, + ], + }; +} + +test("coverage: full coverage produces no synthetic cohort", () => { + const result = reconcileCoverage( + review([ + { file: "src/a.ts", side: "new", startLine: 2, endLine: 3 }, + { file: "src/a.ts", side: "new", startLine: 33, endLine: 33 }, + { file: "src/b.ts", side: "new", startLine: 6, endLine: 6 }, + ]), + FILES + ); + expect(result.uncoveredHunkCount).toBe(0); + expect( + result.review.cohorts.find((c) => c.id === UNCOVERED_COHORT_ID) + ).toBeUndefined(); + expect(result.warnings.length).toBe(0); +}); + +test("coverage: uncovered hunks are swept into a synthetic cohort", () => { + const result = reconcileCoverage( + review([{ file: "src/a.ts", side: "new", startLine: 2, endLine: 3 }]), + FILES + ); + // Second hunk of a.ts and the hunk of b.ts are uncovered. + expect(result.uncoveredHunkCount).toBe(2); + const synthetic = result.review.cohorts.find( + (c) => c.id === UNCOVERED_COHORT_ID + ); + expect(synthetic).toBeDefined(); + const ranges = synthetic!.layers[0]!.ranges; + expect(ranges).toEqual([ + { file: "src/a.ts", side: "new", startLine: 33, endLine: 33 }, + { file: "src/b.ts", side: "new", startLine: 6, endLine: 6 }, + ]); +}); + +test("coverage: bogus ranges are pruned with warnings", () => { + const result = reconcileCoverage( + review([ + { file: "src/a.ts", side: "new", startLine: 500, endLine: 510 }, + { file: "nonexistent.ts", side: "new", startLine: 1, endLine: 5 }, + { file: "src/a.ts", side: "new", startLine: 1, endLine: 40 }, + ]), + FILES + ); + expect(result.warnings.some((w) => w.includes("no hunk"))).toBe(true); + expect(result.warnings.some((w) => w.includes("unknown or binary"))).toBe( + true + ); + // The wide range covers both a.ts hunks; only b.ts is uncovered. + expect(result.uncoveredHunkCount).toBe(1); + const c1 = result.review.cohorts.find((c) => c.id === "c1")!; + expect(c1.layers[0]!.ranges.length).toBe(1); +}); + +test("coverage: deletion-only hunks anchor on the old side", () => { + const files = [ + { + filename: "gone.ts", + patch: `@@ -3,3 +2,0 @@ ctx +-a +-b +-c +`, + }, + ]; + const result = reconcileCoverage(review([]), files); + const synthetic = result.review.cohorts.find( + (c) => c.id === UNCOVERED_COHORT_ID + )!; + expect(synthetic.layers[0]!.ranges).toEqual([ + { file: "gone.ts", side: "old", startLine: 3, endLine: 5 }, + ]); +}); + +test("coverage: empty layers and cohorts are dropped after pruning", () => { + const result = reconcileCoverage( + review([{ file: "nonexistent.ts", side: "new", startLine: 1, endLine: 2 }]), + FILES + ); + expect(result.review.cohorts.find((c) => c.id === "c1")).toBeUndefined(); +}); diff --git a/src/semantic/coverage.ts b/src/semantic/coverage.ts new file mode 100644 index 0000000..540ba44 --- /dev/null +++ b/src/semantic/coverage.ts @@ -0,0 +1,163 @@ +/** + * Coverage invariant enforcement for semantic reviews. + * + * Every hunk in the PR diff must be reachable from at least one layer range, + * or the reviewer can't trust semantic mode to show the whole PR. Provider + * output is reconciled against the actual diff here: + * + * - ranges that match no real hunk are pruned (with a warning) + * - hunks no surviving range covers are collected into a synthetic + * "Uncovered changes" cohort appended to the review + */ + +import type { SemanticCohort, SemanticRange, SemanticReview } from "./schema"; +import { parsePatchHunks, type PatchHunk } from "./patch"; + +export interface DiffFileInput { + filename: string; + /** Unified patch from the GitHub files API. Absent for binary files. */ + patch?: string; +} + +export interface CoverageResult { + review: SemanticReview; + warnings: string[]; + /** Number of hunks that had to be swept into the synthetic cohort. */ + uncoveredHunkCount: number; +} + +export const UNCOVERED_COHORT_ID = "__uncovered__"; + +function overlaps( + aStart: number, + aEnd: number, + bStart: number, + bEnd: number +): boolean { + return aStart <= bEnd && bStart <= aEnd; +} + +/** Whether a range touches the given hunk (full hunk spans, context included). */ +function rangeHitsHunk(range: SemanticRange, hunk: PatchHunk): boolean { + if (range.side === "new") { + return overlaps(range.startLine, range.endLine, hunk.newStart, hunk.newEnd); + } + return overlaps(range.startLine, range.endLine, hunk.oldStart, hunk.oldEnd); +} + +export function reconcileCoverage( + review: SemanticReview, + files: DiffFileInput[] +): CoverageResult { + const warnings: string[] = []; + const hunksByFile = new Map(); + for (const file of files) { + if (file.patch) { + hunksByFile.set(file.filename, parsePatchHunks(file.patch)); + } + } + + const coveredHunks = new Map>(); + const markCovered = (file: string, hunkIndex: number) => { + let set = coveredHunks.get(file); + if (!set) { + set = new Set(); + coveredHunks.set(file, set); + } + set.add(hunkIndex); + }; + + // Prune ranges that don't match any hunk; record what each range covers. + const cohorts: SemanticCohort[] = review.cohorts + .filter((c) => c.id !== UNCOVERED_COHORT_ID) + .map((cohort) => ({ + ...cohort, + layers: cohort.layers + .map((layer) => ({ + ...layer, + ranges: layer.ranges.filter((range) => { + const hunks = hunksByFile.get(range.file); + if (!hunks) { + warnings.push( + `pruned range for unknown or binary file: ${range.file}:${range.startLine}-${range.endLine}` + ); + return false; + } + let hit = false; + for (const hunk of hunks) { + if (rangeHitsHunk(range, hunk)) { + markCovered(range.file, hunk.index); + hit = true; + } + } + if (!hit) { + warnings.push( + `pruned range matching no hunk: ${range.file}:${range.startLine}-${range.endLine} (${range.side})` + ); + } + return hit; + }), + })) + .filter((layer) => layer.ranges.length > 0), + })) + .filter((cohort) => cohort.layers.length > 0); + + if (cohorts.length < review.cohorts.length) { + warnings.push("dropped cohorts/layers left empty after range pruning"); + } + + // Collect uncovered hunks into a synthetic cohort. + const uncoveredRanges: SemanticRange[] = []; + for (const file of files) { + const hunks = hunksByFile.get(file.filename); + if (!hunks) continue; + const covered = coveredHunks.get(file.filename); + for (const hunk of hunks) { + if (covered?.has(hunk.index)) continue; + // Anchor to the changed lines; fall back to the hunk span. Deletion-only + // hunks anchor on the old side since they have no new-side lines. + if (hunk.changedNew) { + uncoveredRanges.push({ + file: file.filename, + side: "new", + startLine: hunk.changedNew.start, + endLine: hunk.changedNew.end, + }); + } else if (hunk.changedOld) { + uncoveredRanges.push({ + file: file.filename, + side: "old", + startLine: hunk.changedOld.start, + endLine: hunk.changedOld.end, + }); + } + } + } + + if (uncoveredRanges.length > 0) { + cohorts.push({ + id: UNCOVERED_COHORT_ID, + title: "Uncovered changes", + summary: + "Changes the analysis did not assign to any cohort. Review these to ensure full coverage of the PR.", + layers: [ + { + id: `${UNCOVERED_COHORT_ID}-layer`, + title: "Unassigned hunks", + summary: + "Hunks not covered by any range in the semantic analysis output.", + ranges: uncoveredRanges, + }, + ], + }); + warnings.push( + `${uncoveredRanges.length} hunk(s) were not covered by the analysis` + ); + } + + return { + review: { ...review, cohorts }, + warnings, + uncoveredHunkCount: uncoveredRanges.length, + }; +} diff --git a/src/semantic/patch.test.ts b/src/semantic/patch.test.ts new file mode 100644 index 0000000..fdec663 --- /dev/null +++ b/src/semantic/patch.test.ts @@ -0,0 +1,59 @@ +import { test, expect } from "bun:test"; +import { parsePatchHunks } from "./patch"; + +const PATCH = `@@ -1,5 +1,6 @@ + import { a } from "./a"; ++import { b } from "./b"; + + export function main() { +- return a(); ++ return b(a()); + } +@@ -20,3 +21,2 @@ export function other() { + const x = 1; +- const y = 2; +- return x + y; ++ return x; +`; + +test("patch: parses hunk headers and line spans", () => { + const hunks = parsePatchHunks(PATCH); + expect(hunks.length).toBe(2); + + const [h1, h2] = hunks; + expect(h1!.newStart).toBe(1); + expect(h1!.newEnd).toBe(6); + expect(h1!.oldStart).toBe(1); + expect(h1!.oldEnd).toBe(5); + // "+import b" lands on new line 2; "+return b(a())" on new line 5. + expect(h1!.changedNew).toEqual({ start: 2, end: 5 }); + // "-return a();" is old line 4. + expect(h1!.changedOld).toEqual({ start: 4, end: 4 }); + + expect(h2!.newStart).toBe(21); + expect(h2!.changedNew).toEqual({ start: 22, end: 22 }); + expect(h2!.changedOld).toEqual({ start: 21, end: 22 }); +}); + +test("patch: deletion-only hunk has no new-side changes", () => { + const patch = `@@ -10,3 +9,0 @@ context +-line one +-line two +-line three +`; + const hunks = parsePatchHunks(patch); + expect(hunks.length).toBe(1); + expect(hunks[0]!.changedNew).toBeNull(); + expect(hunks[0]!.changedOld).toEqual({ start: 10, end: 12 }); +}); + +test("patch: single-line hunk header without counts", () => { + const patch = `@@ -1 +1 @@ +-old ++new +`; + const hunks = parsePatchHunks(patch); + expect(hunks[0]!.newStart).toBe(1); + expect(hunks[0]!.newEnd).toBe(1); + expect(hunks[0]!.changedNew).toEqual({ start: 1, end: 1 }); +}); diff --git a/src/semantic/patch.ts b/src/semantic/patch.ts new file mode 100644 index 0000000..237daa3 --- /dev/null +++ b/src/semantic/patch.ts @@ -0,0 +1,83 @@ +/** + * Minimal parsing of GitHub `patch` strings (from the pulls/{n}/files API) + * into hunk line spans. Used for semantic-review coverage math only — the + * full diff rendering pipeline has its own richer parser. + */ + +export interface PatchHunk { + /** 0-based hunk index within the file's patch. */ + index: number; + /** Full hunk span on the new side (includes context lines). */ + newStart: number; + newEnd: number; + /** Full hunk span on the old side (includes context lines). */ + oldStart: number; + oldEnd: number; + /** Span of added lines on the new side, or null if the hunk only deletes. */ + changedNew: { start: number; end: number } | null; + /** Span of removed lines on the old side, or null if the hunk only adds. */ + changedOld: { start: number; end: number } | null; +} + +const HUNK_HEADER_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; + +export function parsePatchHunks(patch: string): PatchHunk[] { + const hunks: PatchHunk[] = []; + const lines = patch.split("\n"); + + let i = 0; + while (i < lines.length) { + const match = HUNK_HEADER_RE.exec(lines[i]!); + if (!match) { + i++; + continue; + } + const oldStart = parseInt(match[1]!, 10); + const oldLines = match[2] !== undefined ? parseInt(match[2]!, 10) : 1; + const newStart = parseInt(match[3]!, 10); + const newLines = match[4] !== undefined ? parseInt(match[4]!, 10) : 1; + + let oldLine = oldStart; + let newLine = newStart; + let addedMin = Infinity; + let addedMax = -Infinity; + let removedMin = Infinity; + let removedMax = -Infinity; + + i++; + while (i < lines.length && !HUNK_HEADER_RE.test(lines[i]!)) { + const line = lines[i]!; + const prefix = line[0]; + if (prefix === "+") { + addedMin = Math.min(addedMin, newLine); + addedMax = Math.max(addedMax, newLine); + newLine++; + } else if (prefix === "-") { + removedMin = Math.min(removedMin, oldLine); + removedMax = Math.max(removedMax, oldLine); + oldLine++; + } else if (prefix === " " || line === "") { + oldLine++; + newLine++; + } + // "\ No newline at end of file" advances neither side. + i++; + } + + hunks.push({ + index: hunks.length, + newStart, + newEnd: Math.max(newStart, newStart + newLines - 1), + oldStart, + oldEnd: Math.max(oldStart, oldStart + oldLines - 1), + changedNew: + addedMax >= addedMin ? { start: addedMin, end: addedMax } : null, + changedOld: + removedMax >= removedMin + ? { start: removedMin, end: removedMax } + : null, + }); + } + + return hunks; +} diff --git a/src/semantic/schema.test.ts b/src/semantic/schema.test.ts new file mode 100644 index 0000000..1465e37 --- /dev/null +++ b/src/semantic/schema.test.ts @@ -0,0 +1,104 @@ +import { test, expect } from "bun:test"; +import { validateSemanticReview } from "./schema"; + +function validReview() { + return { + version: 1, + provider: "claude", + headSha: "abc123", + generatedAt: "2026-08-25T12:00:00Z", + overview: "Adds a thing.", + cohorts: [ + { + id: "c1", + title: "The thing", + summary: "Adds the thing end to end.", + layers: [ + { + id: "l1", + title: "Contract", + summary: "New types.", + ranges: [ + { file: "src/a.ts", side: "new", startLine: 1, endLine: 10 }, + ], + }, + ], + }, + ], + }; +} + +test("schema: accepts a valid review", () => { + const result = validateSemanticReview(validReview()); + expect(result.ok).toBe(true); + expect(result.value?.cohorts.length).toBe(1); +}); + +test("schema: accepts optional diagram and range summary", () => { + const review = validReview(); + review.cohorts[0]!.layers[0]! = { + ...review.cohorts[0]!.layers[0]!, + diagram: { kind: "sequence", mermaid: "sequenceDiagram\n A->>B: hi" }, + ranges: [ + { + file: "src/a.ts", + side: "new", + startLine: 1, + endLine: 10, + summary: "the range", + }, + ], + } as never; + expect(validateSemanticReview(review).ok).toBe(true); +}); + +test("schema: collects all errors", () => { + const result = validateSemanticReview({ + version: 2, + provider: "", + headSha: "abc", + generatedAt: "not-a-date", + overview: "x", + cohorts: [ + { + id: "c1", + title: "t", + summary: "s", + layers: [ + { + id: "l1", + title: "t", + summary: "s", + diagram: { kind: "pie", mermaid: "" }, + ranges: [ + { file: "f", side: "left", startLine: 0, endLine: -1 }, + { file: "f", side: "new", startLine: 5, endLine: 2 }, + ], + }, + ], + }, + ], + }); + expect(result.ok).toBe(false); + expect(result.errors).toContain("version: expected 1"); + expect(result.errors).toContain("provider: expected non-empty string"); + expect(result.errors).toContain("generatedAt: expected ISO 8601 timestamp"); + expect(result.errors.some((e) => e.includes("diagram.kind"))).toBe(true); + expect(result.errors.some((e) => e.includes("side"))).toBe(true); + expect(result.errors.some((e) => e.includes("endLine < startLine"))).toBe( + true + ); +}); + +test("schema: rejects duplicate ids and empty collections", () => { + const review = validReview() as Record; + (review.cohorts as unknown[]).push(structuredClone(validReview().cohorts[0])); + const result = validateSemanticReview(review); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => e.includes('duplicate id "c1"'))).toBe(true); + + expect(validateSemanticReview({ ...validReview(), cohorts: [] }).ok).toBe( + false + ); + expect(validateSemanticReview(null).ok).toBe(false); +}); diff --git a/src/semantic/schema.ts b/src/semantic/schema.ts new file mode 100644 index 0000000..1894e15 --- /dev/null +++ b/src/semantic/schema.ts @@ -0,0 +1,210 @@ +/** + * Semantic review data model. + * + * This is the contract between the analysis providers (Claude, Codex, ...) + * and the browser UI. Providers emit JSON that must validate against this + * schema before it is cached or rendered. See docs/semantic-review.md. + */ + +export const SEMANTIC_REVIEW_VERSION = 1; + +export interface SemanticRange { + file: string; + /** "old" only for ranges in pure deletions; otherwise "new". */ + side: "new" | "old"; + startLine: number; + endLine: number; + summary?: string; +} + +export interface SemanticDiagram { + kind: "sequence" | "state" | "er" | "flow"; + mermaid: string; +} + +export interface SemanticLayer { + id: string; + title: string; + /** Markdown. */ + summary: string; + diagram?: SemanticDiagram; + ranges: SemanticRange[]; +} + +export interface SemanticCohort { + id: string; + title: string; + summary: string; + layers: SemanticLayer[]; +} + +export interface SemanticReview { + version: typeof SEMANTIC_REVIEW_VERSION; + provider: string; + headSha: string; + /** ISO 8601. */ + generatedAt: string; + /** Markdown, 2-5 sentences. */ + overview: string; + cohorts: SemanticCohort[]; +} + +export interface ValidationResult { + ok: boolean; + errors: string[]; + value?: SemanticReview; +} + +const DIAGRAM_KINDS = new Set(["sequence", "state", "er", "flow"]); + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function isNonEmptyString(v: unknown): v is string { + return typeof v === "string" && v.length > 0; +} + +function isPositiveInt(v: unknown): v is number { + return typeof v === "number" && Number.isInteger(v) && v >= 1; +} + +/** + * Structurally validate provider output. Collects every error rather than + * failing fast so the provider's self-correction round-trip gets a complete + * picture of what to fix. + */ +export function validateSemanticReview(input: unknown): ValidationResult { + const errors: string[] = []; + + if (!isRecord(input)) { + return { ok: false, errors: ["root: expected an object"] }; + } + + if (input.version !== SEMANTIC_REVIEW_VERSION) { + errors.push(`version: expected ${SEMANTIC_REVIEW_VERSION}`); + } + if (!isNonEmptyString(input.provider)) { + errors.push("provider: expected non-empty string"); + } + if (!isNonEmptyString(input.headSha)) { + errors.push("headSha: expected non-empty string"); + } + if ( + !isNonEmptyString(input.generatedAt) || + Number.isNaN(Date.parse(input.generatedAt)) + ) { + errors.push("generatedAt: expected ISO 8601 timestamp"); + } + if (!isNonEmptyString(input.overview)) { + errors.push("overview: expected non-empty string"); + } + + if (!Array.isArray(input.cohorts) || input.cohorts.length === 0) { + errors.push("cohorts: expected non-empty array"); + } else { + const cohortIds = new Set(); + input.cohorts.forEach((cohort, ci) => { + const path = `cohorts[${ci}]`; + if (!isRecord(cohort)) { + errors.push(`${path}: expected an object`); + return; + } + if (!isNonEmptyString(cohort.id)) { + errors.push(`${path}.id: expected non-empty string`); + } else if (cohortIds.has(cohort.id)) { + errors.push(`${path}.id: duplicate id "${cohort.id}"`); + } else { + cohortIds.add(cohort.id); + } + if (!isNonEmptyString(cohort.title)) { + errors.push(`${path}.title: expected non-empty string`); + } + if (!isNonEmptyString(cohort.summary)) { + errors.push(`${path}.summary: expected non-empty string`); + } + if (!Array.isArray(cohort.layers) || cohort.layers.length === 0) { + errors.push(`${path}.layers: expected non-empty array`); + return; + } + const layerIds = new Set(); + cohort.layers.forEach((layer, li) => { + const lpath = `${path}.layers[${li}]`; + if (!isRecord(layer)) { + errors.push(`${lpath}: expected an object`); + return; + } + if (!isNonEmptyString(layer.id)) { + errors.push(`${lpath}.id: expected non-empty string`); + } else if (layerIds.has(layer.id)) { + errors.push(`${lpath}.id: duplicate id "${layer.id}"`); + } else { + layerIds.add(layer.id); + } + if (!isNonEmptyString(layer.title)) { + errors.push(`${lpath}.title: expected non-empty string`); + } + if (!isNonEmptyString(layer.summary)) { + errors.push(`${lpath}.summary: expected non-empty string`); + } + if (layer.diagram !== undefined) { + if (!isRecord(layer.diagram)) { + errors.push(`${lpath}.diagram: expected an object`); + } else { + if (!DIAGRAM_KINDS.has(layer.diagram.kind as string)) { + errors.push( + `${lpath}.diagram.kind: expected one of ${[...DIAGRAM_KINDS].join(", ")}` + ); + } + if (!isNonEmptyString(layer.diagram.mermaid)) { + errors.push( + `${lpath}.diagram.mermaid: expected non-empty string` + ); + } + } + } + if (!Array.isArray(layer.ranges) || layer.ranges.length === 0) { + errors.push(`${lpath}.ranges: expected non-empty array`); + return; + } + layer.ranges.forEach((range, ri) => { + const rpath = `${lpath}.ranges[${ri}]`; + if (!isRecord(range)) { + errors.push(`${rpath}: expected an object`); + return; + } + if (!isNonEmptyString(range.file)) { + errors.push(`${rpath}.file: expected non-empty string`); + } + if (range.side !== "new" && range.side !== "old") { + errors.push(`${rpath}.side: expected "new" or "old"`); + } + if (!isPositiveInt(range.startLine)) { + errors.push(`${rpath}.startLine: expected positive integer`); + } + if (!isPositiveInt(range.endLine)) { + errors.push(`${rpath}.endLine: expected positive integer`); + } + if ( + isPositiveInt(range.startLine) && + isPositiveInt(range.endLine) && + range.endLine < range.startLine + ) { + errors.push(`${rpath}: endLine < startLine`); + } + if ( + range.summary !== undefined && + typeof range.summary !== "string" + ) { + errors.push(`${rpath}.summary: expected string`); + } + }); + }); + }); + } + + if (errors.length > 0) { + return { ok: false, errors }; + } + return { ok: true, errors: [], value: input as unknown as SemanticReview }; +} From 40c73625750a343b6f44f6b81d2956f53d1603f7 Mon Sep 17 00:00:00 2001 From: Taylor Kimmett Date: Tue, 25 Aug 2026 16:03:17 -0400 Subject: [PATCH 05/98] feat: add semantic review local server (providers, jobs, cache, SSE routes) - Pluggable providers: Claude (Agent SDK) and Codex (CLI), both run prompt -> raw text; availability detected from local credentials/PATH - Prompt builder with lockfile/minified patch elision and size capping, plus JSON extraction and a one-shot self-correction prompt - Job runner: validate -> correct -> reconcile coverage -> disk cache (~/.pulldash/semantic, keyed by PR + head SHA) - Hono routes under /api/semantic with SSE job progress streaming; self-disables on hosted (Vercel) deployments Co-Authored-By: Claude Opus 5 --- bun.lock | 193 ++++++++++++++++++++++++++++- package.json | 1 + src/api/api.ts | 4 + src/api/semantic.ts | 117 ++++++++++++++++++ src/semantic/cache.ts | 78 ++++++++++++ src/semantic/jobs.ts | 201 +++++++++++++++++++++++++++++++ src/semantic/prompt.test.ts | 110 +++++++++++++++++ src/semantic/prompt.ts | 185 ++++++++++++++++++++++++++++ src/semantic/providers/claude.ts | 88 ++++++++++++++ src/semantic/providers/codex.ts | 83 +++++++++++++ src/semantic/providers/index.ts | 25 ++++ src/semantic/providers/types.ts | 47 ++++++++ 12 files changed, 1126 insertions(+), 6 deletions(-) create mode 100644 src/api/semantic.ts create mode 100644 src/semantic/cache.ts create mode 100644 src/semantic/jobs.ts create mode 100644 src/semantic/prompt.test.ts create mode 100644 src/semantic/prompt.ts create mode 100644 src/semantic/providers/claude.ts create mode 100644 src/semantic/providers/codex.ts create mode 100644 src/semantic/providers/index.ts create mode 100644 src/semantic/providers/types.ts diff --git a/bun.lock b/bun.lock index 5e67b9a..64e9a22 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "remark-gemoji": "^8.0.0", }, "devDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.245", "@electron/asar": "^4.0.1", "@hono/node-server": "^1.19.6", "@octokit/core": "^7.0.6", @@ -64,6 +65,28 @@ "packages": { "7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.245", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.245", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.245", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.245", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.245", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.245", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.245", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.245", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.245" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-b/SXCxBxZfN4ItHFDUS1uJ3xhI5fOSv3/VxyZvekYmlsbSwZi/75UqKhVlT7qbB1LDJOB48ZmQdCTxWhJWjObA=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.245", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oH1R4yxVKR8oSYMqKHb5NaAPYq8+/enKR0qZKi+lKm6ru64onCmoujT3ilD9rz6TYALCYp2L8Jl2zwOerKSpug=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.245", "", { "os": "darwin", "cpu": "x64" }, "sha512-VtK8dfnF0GhVzJgVylZxPdGRZb21DhOpd04WWefKRfnWVbgjdOtEGpafyMi4ZaND/ftYpc2PB2P7IX8OkIj5iA=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.245", "", { "os": "linux", "cpu": "arm64" }, "sha512-qIi1grLff5a3Z6K9dUsWKrFKcjUuOGErX89DHC9m0UBfGN6swOs6cFj9zvFYhJhLBJeZu0JKyz18BHSXfeu3PA=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.245", "", { "os": "linux", "cpu": "arm64" }, "sha512-81QcZcFL5YcLLdvw2AXBq8Bxs/Fcq4qkq5dqUkjEEIi6jI2+WTk5gCjLsUg9zVaHG1yoVN3HKBFJss+sI+evHA=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.245", "", { "os": "linux", "cpu": "x64" }, "sha512-fvPtGYI61pGRP2rmaYskyLE83PytLLMV/NzDunmDlIRvqkzgQrxDBeOistt6mc29uAlI70EbaKW4zsSft6IhBQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.245", "", { "os": "linux", "cpu": "x64" }, "sha512-3Uxl7YDnqpQHbSpEOYCPytcbcuo1PcdYHDXDpoSotBPlvFOJEiLGCGhwWfEbTO/9RGdp94Qm0T2gGtPNE6QpFg=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.245", "", { "os": "win32", "cpu": "arm64" }, "sha512-N8JTt+DuX2xwbbnLMLDUgmnbtz3VKSGI07WV4WjCEBeb6Olt2KJHDksnJzknrbFBapJz915Vbv2fO+izJcRI1g=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.245", "", { "os": "win32", "cpu": "x64" }, "sha512-C8PHrQBPgExO6sr5bwG+IW7cTR2KDmvAUj9By++u+IiTFEAtPCEjOQ3CFgfPc5wTuGD26SrzWUbdEom5TQp1Dg=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.120.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-ZlvmNFT/iIF6JD13rxbbMWD8nvGR0RaUp6yMQnoc+4Af0YjVVe/bIdW1XSQQsoxXAtg1NaT6Vak0LKFlJ4d37Q=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], "@electron/asar": ["@electron/asar@4.0.1", "", { "dependencies": { "commander": "^13.1.0", "glob": "^11.0.1", "minimatch": "^10.0.1" }, "bin": { "asar": "bin/asar.mjs" } }, "sha512-F4Ykm1jiBGY1WV/o8Q8oFW8Nq0u+S2/vPujzNJtdSJ6C4LHC4CiGLn7c17s7SolZ23gcvCebMncmZtNc+MkxPQ=="], @@ -210,6 +233,8 @@ "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], + "@next/env": ["@next/env@16.0.6", "", {}, "sha512-PFTK/G/vM3UJwK5XDYMFOqt8QW42mmhSgdKDapOlCqBUAOfJN2dyOnASR/xUR/JRrro0pLohh/zOJ77xUQWQAg=="], "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.0.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-AGzKiPlDiui+9JcPRHLI4V9WFTTcKukhJTfK9qu3e0tz+Y/88B7vo5yZoO7UaikplJEHORzG3QaBFQfkjhnL0Q=="], @@ -350,6 +375,8 @@ "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], @@ -422,13 +449,17 @@ "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], - "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], @@ -466,6 +497,8 @@ "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], @@ -486,6 +519,8 @@ "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "cacache": ["cacache@16.1.3", "", { "dependencies": { "@npmcli/fs": "^2.1.0", "@npmcli/move-file": "^2.0.0", "chownr": "^2.0.0", "fs-minipass": "^2.1.0", "glob": "^8.0.1", "infer-owner": "^1.0.4", "lru-cache": "^7.7.1", "minipass": "^3.1.6", "minipass-collect": "^1.0.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "mkdirp": "^1.0.4", "p-map": "^4.0.0", "promise-inflight": "^1.0.1", "rimraf": "^3.0.2", "ssri": "^9.0.0", "tar": "^6.1.11", "unique-filename": "^2.0.0" } }, "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ=="], "cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="], @@ -494,6 +529,8 @@ "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001757", "", {}, "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -554,12 +591,20 @@ "config-file-ts": ["config-file-ts@0.2.8-rc1", "", { "dependencies": { "glob": "^10.3.12", "typescript": "^5.4.3" } }, "sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "core-js": ["core-js@3.47.0", "", {}, "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg=="], "core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="], "cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="], @@ -584,6 +629,8 @@ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -610,6 +657,8 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], "electron": ["electron@39.2.4", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-KxPtwpFceQKSxRtUY39piHLYhJMMyHfOhc70e6zRnKGrbRdK6hzEqssth8IGjlKOdkeT4KCvIEngnNraYk39+g=="], @@ -626,6 +675,8 @@ "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], @@ -650,12 +701,24 @@ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.6.2", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], @@ -668,16 +731,26 @@ "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + + "fast-uri": ["fast-uri@3.1.6", "", {}, "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], "filelist": ["filelist@1.0.4", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], @@ -760,6 +833,8 @@ "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], @@ -786,7 +861,9 @@ "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], - "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + "ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -806,6 +883,8 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="], "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], @@ -816,11 +895,17 @@ "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], + "jose": ["jose@6.2.10", "", {}, "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], @@ -888,6 +973,10 @@ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -946,9 +1035,9 @@ "mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], - "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], @@ -978,7 +1067,7 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], "next": ["next@16.0.6", "", { "dependencies": { "@next/env": "16.0.6", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.6", "@next/swc-darwin-x64": "16.0.6", "@next/swc-linux-arm64-gnu": "16.0.6", "@next/swc-linux-arm64-musl": "16.0.6", "@next/swc-linux-x64-gnu": "16.0.6", "@next/swc-linux-x64-musl": "16.0.6", "@next/swc-win32-arm64-msvc": "16.0.6", "@next/swc-win32-x64-msvc": "16.0.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-2zOZ/4FdaAp5hfCU/RnzARlZzBsjaTZ/XjNQmuyYLluAPM7kcrbIkdeO2SL0Ysd1vnrSgU+GwugfeWX1cUCgCg=="], @@ -992,8 +1081,14 @@ "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], @@ -1012,18 +1107,24 @@ "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-scurry": ["path-scurry@2.0.1", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="], "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="], "png2icons": ["png2icons@2.0.1", "", { "bin": { "png2icons": "./png2icons-cli.js" } }, "sha512-GDEQJr8OG4e6JMp7mABtXFSEpgJa1CCpbQiAR+EjhkHJHnUL9zPPtbOrjsMD8gUbikgv3j7x404b0YJsV3aVFA=="], @@ -1048,12 +1149,20 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], @@ -1094,6 +1203,8 @@ "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], @@ -1110,6 +1221,8 @@ "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -1126,10 +1239,16 @@ "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -1138,6 +1257,14 @@ "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], @@ -1162,8 +1289,12 @@ "ssri": ["ssri@9.0.1", "", { "dependencies": { "minipass": "^3.1.1" } }, "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q=="], + "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], + "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1204,6 +1335,8 @@ "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -1212,6 +1345,8 @@ "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], @@ -1220,6 +1355,8 @@ "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -1246,6 +1383,8 @@ "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], @@ -1256,6 +1395,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], @@ -1292,8 +1433,14 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@develar/schema-utils/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], @@ -1322,6 +1469,10 @@ "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + "@modelcontextprotocol/sdk/@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], + + "@modelcontextprotocol/sdk/hono": ["hono@4.13.4", "", {}, "sha512-AGEwKIyRMHRv1t8Wjwa3LHxQ61X5CqrdFT+4BRNTpqS5aJNnpl5WLjADb7vFlJzI/8uK7T5QLVApCMQKNa3LgQ=="], + "@npmcli/move-file/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -1336,8 +1487,14 @@ "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "ajv-keywords/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "app-builder-lib/@electron/asar": ["@electron/asar@3.2.18", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-2XyvMe3N3Nrs8cV39IKELRHTYUWFKrmqqSY1U+GMlc0jvqjIVnoxhNd2H4JolWQncbJi1DCvb5TNxZuI2fEjWg=="], + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "body-parser/iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + "cacache/glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="], "cacache/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], @@ -1354,12 +1511,18 @@ "dir-compare/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "dmg-license/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "electron-winstaller/@electron/asar": ["@electron/asar@3.2.18", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-2XyvMe3N3Nrs8cV39IKELRHTYUWFKrmqqSY1U+GMlc0jvqjIVnoxhNd2H4JolWQncbJi1DCvb5TNxZuI2fEjWg=="], "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "filelist/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], "hast-util-to-parse5/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], @@ -1374,6 +1537,8 @@ "make-fetch-happen/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "make-fetch-happen/negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], + "matcher/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "minipass-collect/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -1388,14 +1553,20 @@ "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], + "raw-body/iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "socks/ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + "socks-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "ssri/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -1406,6 +1577,10 @@ "tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "@develar/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], @@ -1428,6 +1603,8 @@ "@npmcli/move-file/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "app-builder-lib/@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "app-builder-lib/@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -1444,6 +1621,8 @@ "config-file-ts/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "dmg-license/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "electron-winstaller/@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "electron-winstaller/@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -1456,6 +1635,8 @@ "filelist/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "make-fetch-happen/http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "make-fetch-happen/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], diff --git a/package.json b/package.json index 35b9630..3e87b44 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "url": "https://coder.com" }, "devDependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.245", "@electron/asar": "^4.0.1", "@hono/node-server": "^1.19.6", "@octokit/core": "^7.0.6", diff --git a/src/api/api.ts b/src/api/api.ts index 5b815a0..84a4765 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -1,4 +1,5 @@ import { Hono } from "hono"; +import semantic from "./semantic"; // ============================================================================ // GitHub OAuth App Configuration @@ -18,6 +19,9 @@ const GITHUB_CLIENT_ID = "Ov23ct2e5eDCkITh5xlh"; const api = new Hono() .basePath("/api") + // Semantic review (local-agent-powered; self-disables when hosted) + .route("/semantic", semantic) + // Device Authorization - Step 1: Request device code // Proxies to GitHub since their endpoint doesn't support CORS // For OAuth Apps, we request scopes here. 'repo' gives full access to diff --git a/src/api/semantic.ts b/src/api/semantic.ts new file mode 100644 index 0000000..a42696a --- /dev/null +++ b/src/api/semantic.ts @@ -0,0 +1,117 @@ +/** + * Semantic review API routes. + * + * These run only where a local agent can run (the pulldash CLI/dev server + * and the Electron internal server). On the hosted deployment the providers + * list is empty and the browser hides the feature. All semantic modules are + * imported lazily so hosted bundles never load provider/agent code. + */ + +import { Hono } from "hono"; +import { streamSSE } from "hono/streaming"; + +const HOSTED = !!process.env.VERCEL; + +const semantic = new Hono() + + // Which local agents are usable? Empty list => feature hidden in the UI. + .get("/providers", async (c) => { + if (HOSTED) return c.json({ providers: [] }); + try { + const { listAvailableProviders } = + await import("@/semantic/providers/index"); + return c.json({ providers: await listAvailableProviders() }); + } catch { + return c.json({ providers: [] }); + } + }) + + // Cached result lookup (repo + PR + head SHA). + .get("/result/:owner/:repo/:number/:sha", async (c) => { + if (HOSTED) return c.json({ error: "not available" }, 501); + const { readCachedReview } = await import("@/semantic/cache"); + const number = parseInt(c.req.param("number"), 10); + if (!Number.isInteger(number)) { + return c.json({ error: "invalid PR number" }, 400); + } + const review = await readCachedReview( + c.req.param("owner"), + c.req.param("repo"), + number, + c.req.param("sha") + ); + if (!review) return c.json({ error: "not found" }, 404); + return c.json({ review }); + }) + + // Start (or join) an analysis job. Body: { provider, input: AnalysisInput }. + .post("/analyze", async (c) => { + if (HOSTED) return c.json({ error: "not available" }, 501); + const { startAnalysisJob } = await import("@/semantic/jobs"); + const body = await c.req.json<{ + provider?: string; + input?: import("@/semantic/providers/types").AnalysisInput; + }>(); + const { provider, input } = body; + if ( + !provider || + !input?.owner || + !input.repo || + !Number.isInteger(input.number) || + !input.headSha || + !Array.isArray(input.files) + ) { + return c.json({ error: "invalid request body" }, 400); + } + const job = startAnalysisJob(provider, input); + return c.json({ jobId: job.id, status: job.status }); + }) + + // Stream job progress via SSE; replays the full progress log on connect. + .get("/jobs/:id", async (c) => { + if (HOSTED) return c.json({ error: "not available" }, 501); + const { getJob } = await import("@/semantic/jobs"); + const job = getJob(c.req.param("id")); + if (!job) return c.json({ error: "not found" }, 404); + + return streamSSE(c, async (stream) => { + let cursor = 0; + let alive = true; + stream.onAbort(() => { + alive = false; + }); + + while (alive) { + while (cursor < job.progress.length) { + await stream.writeSSE({ + event: "progress", + data: job.progress[cursor++]!, + }); + } + if (job.status !== "running") { + await stream.writeSSE({ + event: job.status, + data: JSON.stringify( + job.status === "done" + ? { review: job.result, warnings: job.warnings } + : { error: job.error } + ), + }); + return; + } + await new Promise((r) => setTimeout(r, 300)); + } + }); + }) + + // Cancel a running job. + .delete("/jobs/:id", async (c) => { + if (HOSTED) return c.json({ error: "not available" }, 501); + const { getJob } = await import("@/semantic/jobs"); + const job = getJob(c.req.param("id")); + if (!job) return c.json({ error: "not found" }, 404); + job.abort.abort(); + return c.json({ ok: true }); + }); + +export default semantic; diff --git a/src/semantic/cache.ts b/src/semantic/cache.ts new file mode 100644 index 0000000..75464fa --- /dev/null +++ b/src/semantic/cache.ts @@ -0,0 +1,78 @@ +/** + * Disk cache for semantic reviews, keyed by (owner, repo, number, headSha). + * Entries are immutable per SHA; historical revisions accumulate naturally. + * Node-only; import lazily from API routes. + */ + +import { mkdir, readFile, writeFile } from "fs/promises"; +import { homedir } from "os"; +import { join } from "path"; +import type { SemanticReview } from "./schema"; + +function cacheDir(owner: string, repo: string, number: number): string { + const base = + process.env.PULLDASH_SEMANTIC_CACHE_DIR ?? + join(homedir(), ".pulldash", "semantic"); + // owner/repo are path-unsafe as-is; keep them readable but flat. + const key = `${owner}--${repo}--${number}`.replace(/[^a-zA-Z0-9._-]/g, "_"); + return join(base, key); +} + +function entryPath( + owner: string, + repo: string, + number: number, + headSha: string +): string { + const sha = headSha.replace(/[^a-zA-Z0-9]/g, ""); + return join(cacheDir(owner, repo, number), `${sha}.json`); +} + +export async function readCachedReview( + owner: string, + repo: string, + number: number, + headSha: string +): Promise { + try { + const raw = await readFile( + entryPath(owner, repo, number, headSha), + "utf-8" + ); + return JSON.parse(raw) as SemanticReview; + } catch { + return null; + } +} + +export async function writeCachedReview( + owner: string, + repo: string, + number: number, + review: SemanticReview +): Promise { + const dir = cacheDir(owner, repo, number); + await mkdir(dir, { recursive: true }); + await writeFile( + entryPath(owner, repo, number, review.headSha), + JSON.stringify(review, null, 2) + ); +} + +/** Keep the raw provider output next to the cache entry for debugging. */ +export async function writeRawOutput( + owner: string, + repo: string, + number: number, + headSha: string, + raw: string +): Promise { + try { + const dir = cacheDir(owner, repo, number); + await mkdir(dir, { recursive: true }); + const sha = headSha.replace(/[^a-zA-Z0-9]/g, ""); + await writeFile(join(dir, `${sha}.raw.txt`), raw); + } catch { + // Debug artifact only - never fail the job over it. + } +} diff --git a/src/semantic/jobs.ts b/src/semantic/jobs.ts new file mode 100644 index 0000000..80b4523 --- /dev/null +++ b/src/semantic/jobs.ts @@ -0,0 +1,201 @@ +/** + * In-memory job runner for semantic review analysis. Node-only. + * + * A job runs: build prompt -> provider -> extract JSON -> validate (with one + * self-correction round-trip) -> reconcile coverage -> cache to disk. + * Progress messages accumulate so a reconnecting SSE client can replay them. + */ + +import type { AnalysisInput } from "./providers/types"; +import { getProvider } from "./providers"; +import { + buildAnalysisPrompt, + buildCorrectionPrompt, + extractJson, +} from "./prompt"; +import { validateSemanticReview, type SemanticReview } from "./schema"; +import { reconcileCoverage } from "./coverage"; +import { writeCachedReview, writeRawOutput } from "./cache"; + +export type JobStatus = "running" | "done" | "error"; + +export interface SemanticJob { + id: string; + status: JobStatus; + /** Progress log; index doubles as an SSE replay cursor. */ + progress: string[]; + result?: SemanticReview; + warnings?: string[]; + error?: string; + createdAt: number; + abort: AbortController; +} + +const jobs = new Map(); +const JOB_TTL_MS = 60 * 60 * 1000; + +let nextId = 1; + +function pruneOldJobs() { + const cutoff = Date.now() - JOB_TTL_MS; + for (const [id, job] of jobs) { + if (job.status !== "running" && job.createdAt < cutoff) { + jobs.delete(id); + } + } +} + +export function getJob(id: string): SemanticJob | undefined { + return jobs.get(id); +} + +/** One analysis per PR head at a time; reuse a running job on re-request. */ +export function findRunningJob(key: string): SemanticJob | undefined { + return jobs.get(runningKeys.get(key) ?? ""); +} + +const runningKeys = new Map(); + +export function jobKey(input: AnalysisInput): string { + return `${input.owner}/${input.repo}#${input.number}@${input.headSha}`; +} + +export function startAnalysisJob( + providerId: string, + input: AnalysisInput +): SemanticJob { + pruneOldJobs(); + + const key = jobKey(input); + const existing = findRunningJob(key); + if (existing) return existing; + + const job: SemanticJob = { + id: `job-${nextId++}`, + status: "running", + progress: [], + createdAt: Date.now(), + abort: new AbortController(), + }; + jobs.set(job.id, job); + runningKeys.set(key, job.id); + + void runJob(job, providerId, input).finally(() => { + if (runningKeys.get(key) === job.id) runningKeys.delete(key); + }); + + return job; +} + +async function runJob( + job: SemanticJob, + providerId: string, + input: AnalysisInput +): Promise { + const log = (message: string) => { + // Collapse consecutive duplicates (providers emit repeated heartbeats). + if (job.progress[job.progress.length - 1] !== message) { + job.progress.push(message); + } + }; + + try { + const provider = getProvider(providerId); + if (!provider) { + throw new Error(`unknown provider: ${providerId}`); + } + + log(`analyzing ${input.files.length} files with ${provider.displayName}`); + const prompt = buildAnalysisPrompt(input); + const runOptions = { onProgress: log, signal: job.abort.signal }; + + let raw = await provider.run(prompt, runOptions); + await writeRawOutput( + input.owner, + input.repo, + input.number, + input.headSha, + raw + ); + + log("validating analysis output"); + let review = tryValidate(raw, input, provider.id); + + if (typeof review === "object" && "errors" in review) { + // One self-correction round-trip. + log( + `output failed validation (${review.errors.length} errors), asking ${provider.displayName} to correct` + ); + raw = await provider.run( + buildCorrectionPrompt(prompt, raw, review.errors), + runOptions + ); + await writeRawOutput( + input.owner, + input.repo, + input.number, + input.headSha, + raw + ); + review = tryValidate(raw, input, provider.id); + if ("errors" in review) { + throw new Error( + `provider output failed validation twice: ${review.errors.slice(0, 5).join("; ")}` + ); + } + } + + log("reconciling coverage against the diff"); + const { + review: reconciled, + warnings, + uncoveredHunkCount, + } = reconcileCoverage(review.value, input.files); + if (uncoveredHunkCount > 0) { + log(`${uncoveredHunkCount} hunk(s) swept into "Uncovered changes"`); + } + + await writeCachedReview(input.owner, input.repo, input.number, reconciled); + job.result = reconciled; + job.warnings = warnings; + job.status = "done"; + log("analysis complete"); + } catch (err) { + job.status = "error"; + job.error = (err as Error).message; + job.progress.push(`error: ${job.error}`); + } +} + +function tryValidate( + raw: string, + input: AnalysisInput, + providerId: string +): { value: SemanticReview } | { errors: string[] } { + let parsed: unknown; + try { + parsed = extractJson(raw); + } catch (err) { + return { errors: [(err as Error).message] }; + } + + // Normalize fields the provider is prone to getting wrong before + // structural validation: these are ours to pin, not the model's. + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + const obj = parsed as Record; + obj.provider = providerId; + obj.headSha = input.headSha; + if ( + typeof obj.generatedAt !== "string" || + Number.isNaN(Date.parse(obj.generatedAt)) + ) { + obj.generatedAt = new Date().toISOString(); + } + } + + const result = validateSemanticReview(parsed); + if (result.ok && result.value) { + return { value: result.value }; + } + return { errors: result.errors }; +} diff --git a/src/semantic/prompt.test.ts b/src/semantic/prompt.test.ts new file mode 100644 index 0000000..67726c8 --- /dev/null +++ b/src/semantic/prompt.test.ts @@ -0,0 +1,110 @@ +import { test, expect } from "bun:test"; +import { + buildAnalysisPrompt, + buildCorrectionPrompt, + extractJson, + isElidedPatch, +} from "./prompt"; +import type { AnalysisInput } from "./providers/types"; + +const INPUT: AnalysisInput = { + owner: "coder", + repo: "pulldash", + number: 42, + headSha: "abc123", + title: "Add feature", + body: "Does the thing.", + files: [ + { + filename: "src/a.ts", + status: "modified", + additions: 2, + deletions: 0, + patch: "@@ -1,2 +1,4 @@\n ctx\n+one\n+two\n ctx", + }, + { + filename: "bun.lock", + status: "modified", + additions: 100, + deletions: 100, + patch: "@@ -1 +1 @@\n-x\n+y", + }, + { filename: "logo.png", status: "added", additions: 0, deletions: 0 }, + ], +}; + +test("prompt: includes PR metadata and real patches, elides lockfiles/binaries", () => { + const prompt = buildAnalysisPrompt(INPUT); + expect(prompt).toContain("coder/pulldash#42"); + expect(prompt).toContain("Add feature"); + expect(prompt).toContain("+one"); + // Lockfile patch elided but the file is still listed. + expect(prompt).not.toContain("-x"); + expect(prompt).toContain("bun.lock"); + expect(prompt).toContain("logo.png"); + expect(prompt).toContain("Files listed without patches"); +}); + +test("prompt: isElidedPatch matches lockfiles and generated files", () => { + for (const f of [ + "bun.lock", + "sub/dir/package-lock.json", + "yarn.lock", + "go.sum", + "app.min.js", + "styles.min.css", + "bundle.js.map", + "component.test.tsx.snap", + ]) { + expect(isElidedPatch(f), f).toBe(true); + } + for (const f of ["src/lock.ts", "gosum.go", "min.js.ts", "locker/file.ts"]) { + expect(isElidedPatch(f), f).toBe(false); + } +}); + +test("prompt: oversized diffs elide the largest patches first", () => { + const big = { + ...INPUT, + files: [ + { + filename: "small.ts", + status: "modified", + additions: 1, + deletions: 0, + patch: "@@ -1 +1,2 @@\n ctx\n+tiny", + }, + { + filename: "huge.ts", + status: "modified", + additions: 1, + deletions: 0, + patch: "@@ -1 +1,2 @@\n ctx\n+" + "x".repeat(700_000), + }, + ], + }; + const prompt = buildAnalysisPrompt(big); + expect(prompt).toContain("+tiny"); + expect(prompt).not.toContain("x".repeat(1000)); + expect(prompt).toContain( + "huge.ts (modified, +1/-0) [patch too large: elided]" + ); +}); + +test("prompt: correction prompt embeds errors and previous output", () => { + const prompt = buildCorrectionPrompt("ORIGINAL", "BAD OUTPUT", ["e1", "e2"]); + expect(prompt).toContain("ORIGINAL"); + expect(prompt).toContain("BAD OUTPUT"); + expect(prompt).toContain("- e1"); +}); + +test("extractJson: direct, fenced, and embedded JSON", () => { + expect(extractJson('{"a":1}')).toEqual({ a: 1 }); + expect(extractJson('Here you go:\n```json\n{"a":1}\n```\nDone.')).toEqual({ + a: 1, + }); + expect(extractJson('Sure! {"a":{"b":2}} hope that helps')).toEqual({ + a: { b: 2 }, + }); + expect(() => extractJson("no json here")).toThrow(); +}); diff --git a/src/semantic/prompt.ts b/src/semantic/prompt.ts new file mode 100644 index 0000000..5eb6fa2 --- /dev/null +++ b/src/semantic/prompt.ts @@ -0,0 +1,185 @@ +/** + * Prompt construction and output extraction for semantic review analysis. + * Pure functions - shared by all providers and unit-testable. + */ + +import type { AnalysisInput } from "./providers/types"; +import { SEMANTIC_REVIEW_VERSION } from "./schema"; + +/** Files whose patch bodies are noise; listed by name only. */ +const ELIDED_PATCH_RE = + /(^|\/)(package-lock\.json|bun\.lock|bun\.lockb|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|go\.sum|composer\.lock|Gemfile\.lock|poetry\.lock|uv\.lock)$|\.(min\.js|min\.css|map|snap)$/; + +/** Rough ceiling on prompt size; ~4 chars/token, aim under ~150k tokens. */ +const MAX_PROMPT_CHARS = 600_000; + +export function isElidedPatch(filename: string): boolean { + return ELIDED_PATCH_RE.test(filename); +} + +export function buildAnalysisPrompt(input: AnalysisInput): string { + const header = `You are analyzing a pull request to produce a "semantic review": a reorganization of the diff from a flat file list into a guided, dependency-ordered walkthrough. + +PR: ${input.owner}/${input.repo}#${input.number} (head ${input.headSha}) +Title: ${input.title} + +Description: +${input.body ? input.body.trim() : "(no description)"} + +## Your task + +Partition ALL hunks of the diff below into cohorts and layers: + +1. **Cohorts**: 2-7 independent, logically related groups of changes, named by intent (e.g. "Auth token refresh", "Config plumbing"). Grouping is hunk-level: one file's hunks may belong to different cohorts. +2. **Layers**: within each cohort, an ordered reading sequence. Foundational changes first (types, data shapes, contracts), then consumers/call sites, then tests. Each layer anchors to exact line ranges in the diff. +3. **Coverage**: every hunk must be covered by at least one layer range. Do not skip anything. +4. **Summaries**: explain WHY the change exists in plain language; never restate the diff mechanically. +5. **Diagrams**: attach a Mermaid diagram to a layer ONLY when it introduces an API interaction, state machine, or schema relationship where a visual genuinely helps. Most layers should have none. + +## Line-range rules + +- Ranges use NEW-side line numbers (the file after the change), i.e. the line numbers implied by the "+" side of each hunk header. +- Only for hunks that purely delete lines (no added lines), use side "old" with OLD-side line numbers. +- A range must intersect actual changed lines; do not invent ranges outside the hunks shown. + +## Output + +Output ONLY a JSON object (no prose, no code fences) matching: + +{ + "version": ${SEMANTIC_REVIEW_VERSION}, + "provider": "", + "headSha": "${input.headSha}", + "generatedAt": "", + "overview": "<2-5 sentence markdown overview of the whole PR>", + "cohorts": [ + { + "id": "", + "title": "", + "summary": "<1-3 sentences>", + "layers": [ + { + "id": "", + "title": "", + "summary": "", + "diagram": { "kind": "sequence|state|er|flow", "mermaid": "" }, + "ranges": [ + { "file": "", "side": "new", "startLine": 1, "endLine": 10, "summary": "" } + ] + } + ] + } + ] +} + +The "diagram" field is optional. The "summary" field on ranges is optional. + +## The diff + +`; + + const sections: string[] = []; + let elided: string[] = []; + for (const file of input.files) { + const label = `${file.filename} (${file.status}, +${file.additions}/-${file.deletions})`; + if (!file.patch) { + elided.push(`${label} [binary or too large: no patch]`); + } else if (isElidedPatch(file.filename)) { + elided.push(`${label} [generated/lockfile: patch elided]`); + } else { + sections.push(`### ${label}\n${file.patch}`); + } + } + + let body = sections.join("\n\n"); + + // Stay under the prompt ceiling: elide the largest patches first. + if (header.length + body.length > MAX_PROMPT_CHARS) { + const sorted = input.files + .filter((f) => f.patch && !isElidedPatch(f.filename)) + .sort((a, b) => (b.patch?.length ?? 0) - (a.patch?.length ?? 0)); + const dropped = new Set(); + let size = header.length + body.length; + for (const file of sorted) { + if (size <= MAX_PROMPT_CHARS) break; + dropped.add(file.filename); + size -= file.patch!.length; + } + body = input.files + .filter((f) => f.patch && !isElidedPatch(f.filename)) + .map((f) => + dropped.has(f.filename) + ? null + : `### ${f.filename} (${f.status}, +${f.additions}/-${f.deletions})\n${f.patch}` + ) + .filter(Boolean) + .join("\n\n"); + for (const name of dropped) { + const f = input.files.find((x) => x.filename === name)!; + elided.push( + `${f.filename} (${f.status}, +${f.additions}/-${f.deletions}) [patch too large: elided]` + ); + } + } + + const elidedSection = + elided.length > 0 + ? `\n\n### Files listed without patches\n${elided.map((e) => `- ${e}`).join("\n")}\nAssign each of these to a sensible cohort with a range of 1-1 on side "new" if changed lines are unknown.\n` + : ""; + + return header + body + elidedSection; +} + +/** + * Build the self-correction prompt sent back to a provider whose output + * failed validation. + */ +export function buildCorrectionPrompt( + originalPrompt: string, + previousOutput: string, + errors: string[] +): string { + return `${originalPrompt} + +## Correction required + +Your previous output failed validation. Errors: +${errors.map((e) => `- ${e}`).join("\n")} + +Previous output: +${previousOutput} + +Output ONLY the corrected JSON object.`; +} + +/** + * Extract a JSON object from agent output that may be wrapped in prose or + * code fences. + */ +export function extractJson(text: string): unknown { + const trimmed = text.trim(); + + // Direct parse first. + try { + return JSON.parse(trimmed); + } catch {} + + // Fenced block. + const fence = /```(?:json)?\s*\n([\s\S]*?)\n```/.exec(trimmed); + if (fence) { + try { + return JSON.parse(fence[1]!); + } catch {} + } + + // First "{" to last "}". + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start !== -1 && end > start) { + try { + return JSON.parse(trimmed.slice(start, end + 1)); + } catch {} + } + + throw new Error("no parseable JSON object found in provider output"); +} diff --git a/src/semantic/providers/claude.ts b/src/semantic/providers/claude.ts new file mode 100644 index 0000000..5b10d9f --- /dev/null +++ b/src/semantic/providers/claude.ts @@ -0,0 +1,88 @@ +/** + * Claude provider - runs the analysis through the Claude Agent SDK, which + * rides the user's existing Claude Code login/subscription (no API key + * handling in pulldash). + * + * Node-only. Import lazily from API routes so browser/Vercel bundles never + * pull in the SDK. + */ + +import { existsSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; +import type { SemanticProvider, ProviderRunOptions } from "./types"; + +export const claudeProvider: SemanticProvider = { + id: "claude", + displayName: "Claude", + + async available(): Promise { + try { + // The Agent SDK bundles the Claude Code binary; what actually gates it + // is credentials. Look for a Claude Code login or an API key. + if (process.env.ANTHROPIC_API_KEY) return true; + const home = homedir(); + return ( + existsSync(join(home, ".claude", ".credentials.json")) || + existsSync(join(home, ".config", "claude", ".credentials.json")) || + existsSync(join(home, ".claude.json")) + ); + } catch { + return false; + } + }, + + async run(prompt: string, options: ProviderRunOptions): Promise { + const { query } = await import("@anthropic-ai/claude-agent-sdk"); + + options.onProgress("starting Claude agent"); + const controller = new AbortController(); + const onAbort = () => controller.abort(); + options.signal.addEventListener("abort", onAbort, { once: true }); + + let resultText = ""; + let assistantText = ""; + try { + const stream = query({ + prompt, + options: { + // Pure analysis over an inlined diff: no tools needed. + allowedTools: [], + disallowedTools: ["Bash", "Write", "Edit"], + maxTurns: 1, + abortController: controller, + }, + }); + + for await (const message of stream) { + if (options.signal.aborted) break; + const m = message as { + type: string; + subtype?: string; + result?: string; + message?: { content?: Array<{ type: string; text?: string }> }; + }; + if (m.type === "assistant") { + for (const block of m.message?.content ?? []) { + if (block.type === "text" && block.text) { + assistantText += block.text; + } + } + options.onProgress("Claude is analyzing the diff"); + } else if (m.type === "result") { + if (typeof m.result === "string") { + resultText = m.result; + } + } + } + } finally { + options.signal.removeEventListener("abort", onAbort); + } + + const output = resultText || assistantText; + if (!output) { + throw new Error("Claude agent produced no output"); + } + return output; + }, +}; diff --git a/src/semantic/providers/codex.ts b/src/semantic/providers/codex.ts new file mode 100644 index 0000000..3e28c33 --- /dev/null +++ b/src/semantic/providers/codex.ts @@ -0,0 +1,83 @@ +/** + * Codex provider - shells out to the `codex` CLI, riding the user's ChatGPT + * subscription auth. Node-only; import lazily from API routes. + */ + +import { spawn } from "child_process"; +import type { SemanticProvider, ProviderRunOptions } from "./types"; + +function which(bin: string): Promise { + return new Promise((resolve) => { + const proc = spawn(process.platform === "win32" ? "where" : "which", [bin]); + proc.on("error", () => resolve(false)); + proc.on("close", (code) => resolve(code === 0)); + }); +} + +export const codexProvider: SemanticProvider = { + id: "codex", + displayName: "Codex", + + async available(): Promise { + try { + return await which("codex"); + } catch { + return false; + } + }, + + run(prompt: string, options: ProviderRunOptions): Promise { + return new Promise((resolve, reject) => { + options.onProgress("starting Codex agent"); + // Read-only sandbox; prompt on stdin ("-") to avoid argv size limits. + const proc = spawn( + "codex", + ["exec", "--skip-git-repo-check", "--sandbox", "read-only", "-"], + { stdio: ["pipe", "pipe", "pipe"] } + ); + + let stdout = ""; + let stderr = ""; + let progressBytes = 0; + + const onAbort = () => proc.kill("SIGTERM"); + options.signal.addEventListener("abort", onAbort, { once: true }); + + proc.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + progressBytes += chunk.length; + if (progressBytes > 4096) { + progressBytes = 0; + options.onProgress("Codex is analyzing the diff"); + } + }); + proc.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + proc.on("error", (err) => { + options.signal.removeEventListener("abort", onAbort); + reject(new Error(`failed to spawn codex: ${err.message}`)); + }); + proc.on("close", (code) => { + options.signal.removeEventListener("abort", onAbort); + if (options.signal.aborted) { + reject(new Error("analysis aborted")); + } else if (code !== 0) { + reject( + new Error( + `codex exited with code ${code}: ${stderr.slice(-2000) || stdout.slice(-2000)}` + ) + ); + } else if (!stdout.trim()) { + reject(new Error("Codex produced no output")); + } else { + resolve(stdout); + } + }); + + proc.stdin.write(prompt); + proc.stdin.end(); + }); + }, +}; diff --git a/src/semantic/providers/index.ts b/src/semantic/providers/index.ts new file mode 100644 index 0000000..0569c1a --- /dev/null +++ b/src/semantic/providers/index.ts @@ -0,0 +1,25 @@ +/** + * Provider registry. Node-only; import lazily from API routes. + */ + +import type { ProviderInfo, SemanticProvider } from "./types"; +import { claudeProvider } from "./claude"; +import { codexProvider } from "./codex"; + +const PROVIDERS: SemanticProvider[] = [claudeProvider, codexProvider]; + +export function getProvider(id: string): SemanticProvider | undefined { + return PROVIDERS.find((p) => p.id === id); +} + +export async function listAvailableProviders(): Promise { + const availability = await Promise.all( + PROVIDERS.map(async (p) => ({ + provider: p, + available: await p.available(), + })) + ); + return availability + .filter((a) => a.available) + .map((a) => ({ id: a.provider.id, displayName: a.provider.displayName })); +} diff --git a/src/semantic/providers/types.ts b/src/semantic/providers/types.ts new file mode 100644 index 0000000..138f945 --- /dev/null +++ b/src/semantic/providers/types.ts @@ -0,0 +1,47 @@ +/** + * Provider plugin interface for semantic review analysis. + * + * Providers are local agents (Claude via the Agent SDK, Codex via its CLI) + * that ride the user's existing subscription auth. Each receives a fully + * rendered prompt and returns raw text; JSON extraction, validation, and + * coverage reconciliation happen in the job runner so every provider is held + * to the same contract. + */ + +export interface AnalysisInput { + owner: string; + repo: string; + number: number; + headSha: string; + title: string; + body: string; + files: AnalysisFile[]; +} + +export interface AnalysisFile { + filename: string; + status: string; + additions: number; + deletions: number; + /** Unified patch from the GitHub files API. Absent for binary files. */ + patch?: string; +} + +export interface ProviderRunOptions { + onProgress: (message: string) => void; + signal: AbortSignal; +} + +export interface SemanticProvider { + id: string; + displayName: string; + /** Cheap availability probe (auth/CLI detection). Must never throw. */ + available(): Promise; + /** Run the prompt and return the agent's final text output. */ + run(prompt: string, options: ProviderRunOptions): Promise; +} + +export interface ProviderInfo { + id: string; + displayName: string; +} From b1804042bd5b1a2f716b99a403b9048ca50d9234 Mon Sep 17 00:00:00 2001 From: Taylor Kimmett Date: Tue, 25 Aug 2026 16:08:26 -0400 Subject: [PATCH 06/98] feat: add semantic review browser state and trigger button - Semantic slice in PRReviewStore: provider list, cached-result load, analysis job lifecycle (SSE progress, cancel, dispose), view mode, per-PR reviewed-layer persistence - semantic-client.ts: fetch/EventSource wrappers that degrade to a hidden feature when no local providers exist (hosted deployment) - SemanticReviewButton in the PR header: provider picker, live progress dropdown with cancel, error/retry, and a Files/Semantic view toggle Co-Authored-By: Claude Opus 5 --- src/browser/components/pr-review.tsx | 8 +- .../components/semantic-review-button.tsx | 198 ++++++++++++++++++ src/browser/contexts/pr-review/index.tsx | 180 ++++++++++++++++ src/browser/lib/semantic-client.ts | 117 +++++++++++ 4 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 src/browser/components/semantic-review-button.tsx create mode 100644 src/browser/lib/semantic-client.ts diff --git a/src/browser/components/pr-review.tsx b/src/browser/components/pr-review.tsx index ee46640..f9dee13 100644 --- a/src/browser/components/pr-review.tsx +++ b/src/browser/components/pr-review.tsx @@ -47,6 +47,7 @@ import { } from "../ui/tooltip"; import { cn } from "../cn"; import { PRHeader } from "./pr-header"; +import { SemanticReviewButton } from "./semantic-review-button"; import { FileTree } from "./file-tree"; import { isTestFile } from "@/browser/lib/test-file"; import { FileHeader } from "./file-header"; @@ -423,7 +424,12 @@ function PRReviewLayout() { owner={owner} repo={repo} onToggleSidebar={() => setMobileSidebarOpen(!mobileSidebarOpen)} - rightContent={canWrite ? : undefined} + rightContent={ + <> + + {canWrite && } + + } />
diff --git a/src/browser/components/semantic-review-button.tsx b/src/browser/components/semantic-review-button.tsx new file mode 100644 index 0000000..8cd21d4 --- /dev/null +++ b/src/browser/components/semantic-review-button.tsx @@ -0,0 +1,198 @@ +import { Loader2, Sparkles, TriangleAlert, X } from "lucide-react"; +import { memo, useEffect } from "react"; +import { cn } from "../cn"; +import { usePRReviewSelector, usePRReviewStore } from "../contexts/pr-review"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; + +/** + * Header button for the semantic review feature. + * + * Renders nothing when no local agent providers are available (hosted + * deployment). Otherwise: + * - idle: "Semantic review" -> provider picker -> starts analysis + * - running: spinner + live progress; dropdown shows the log + cancel + * - done: toggles between file view and semantic view; offers re-run + * - error: shows the failure + retry per provider + */ +export const SemanticReviewButton = memo(function SemanticReviewButton() { + const store = usePRReviewStore(); + const providers = usePRReviewSelector((s) => s.semanticProviders); + const status = usePRReviewSelector((s) => s.semanticStatus); + const review = usePRReviewSelector((s) => s.semanticReview); + const progress = usePRReviewSelector((s) => s.semanticProgress); + const error = usePRReviewSelector((s) => s.semanticError); + const viewMode = usePRReviewSelector((s) => s.viewMode); + + // Load providers + cached result once; tear down any live SSE stream on + // unmount (the job itself keeps running server-side and lands in cache). + useEffect(() => { + void store.initSemanticReview(); + return () => store.disposeSemantic(); + }, [store]); + + if (!providers || providers.length === 0) return null; + + if (status === "running") { + const latest = progress[progress.length - 1]; + return ( + + + + + + + Semantic analysis in progress + + +
+ {progress.map((line, i) => ( +
+ {line} +
+ ))} + {progress.length === 0 && ( +
Starting…
+ )} +
+ + store.cancelSemanticAnalysis()} + className="text-destructive" + > + + Cancel analysis + +
+
+ ); + } + + if (status === "done" && review) { + const semanticActive = viewMode === "semantic"; + return ( +
+ + + + + + + + Re-run analysis + + + {providers.map((p) => ( + store.startSemanticAnalysis(p.id)} + > + + {p.displayName} + + ))} + + +
+ ); + } + + // idle or error + return ( + + + + + + {status === "error" && ( + <> +
+ {error ?? "Analysis failed"} +
+ + + )} + + {status === "error" ? "Retry with" : "Analyze with"} + + + {providers.map((p) => ( + store.startSemanticAnalysis(p.id)} + > + + {p.displayName} + + ))} +
+
+ ); +}); diff --git a/src/browser/contexts/pr-review/index.tsx b/src/browser/contexts/pr-review/index.tsx index 941f529..909fe79 100644 --- a/src/browser/contexts/pr-review/index.tsx +++ b/src/browser/contexts/pr-review/index.tsx @@ -18,6 +18,15 @@ import { type MentionUser, } from "@/browser/ui/markdown"; import { isTestFile } from "@/browser/lib/test-file"; +import { + cancelSemanticJob, + fetchCachedSemanticReview, + fetchSemanticProviders, + startSemanticAnalysis as apiStartSemanticAnalysis, + streamSemanticJob, + type ProviderInfo, +} from "@/browser/lib/semantic-client"; +import type { SemanticReview } from "@/semantic/schema"; import { type GitHubStore, type Review, @@ -236,6 +245,17 @@ interface PRReviewState { focusedPendingCommentId: string | null; editingPendingCommentId: string | null; + // Semantic review (local-agent-powered; hidden when no providers) + semanticProviders: ProviderInfo[] | null; // null = not yet loaded + semanticStatus: "idle" | "running" | "done" | "error"; + semanticReview: SemanticReview | null; + semanticWarnings: string[]; + semanticProgress: string[]; + semanticError: string | null; + viewMode: "files" | "semantic"; + reviewedLayers: Set; // "cohortId/layerId", persisted per PR + selectedLayerId: string | null; // "cohortId/layerId" + // Review pendingReviewId: number | null; reviewBody: string; @@ -292,6 +312,9 @@ export class PRReviewStore { private github: GitHubStore; // Track recently approved workflow IDs to filter out stale API responses private recentlyApprovedWorkflowIds = new Set(); + // Semantic analysis job tracking (SSE subscription lifecycle) + private semanticJobId: string | null = null; + private semanticUnsubscribe: (() => void) | null = null; constructor( github: GitHubStore, @@ -336,6 +359,15 @@ export class PRReviewStore { } } catch {} + // Load reviewed semantic layers from localStorage + let reviewedLayers = new Set(); + try { + const stored = localStorage.getItem(`${this.storageKey}-semantic-layers`); + if (stored) { + reviewedLayers = new Set(JSON.parse(stored)); + } + } catch {} + // Sort files to match file tree order (folders first, then alphabetically) const sortedFiles = sortFilesLikeTree(initialState.files); @@ -402,6 +434,15 @@ export class PRReviewStore { replyingToCommentId: null, focusedPendingCommentId: null, editingPendingCommentId: null, + semanticProviders: null, + semanticStatus: "idle", + semanticReview: null, + semanticWarnings: [], + semanticProgress: [], + semanticError: null, + viewMode: "files", + reviewedLayers, + selectedLayerId: null, pendingReviewId: null, pendingComments, reviewBody, @@ -708,6 +749,145 @@ export class PRReviewStore { this.setDiffViewMode(newMode); }; + // --------------------------------------------------------------------------- + // Semantic Review Actions + // --------------------------------------------------------------------------- + + /** + * Load available providers and any cached review for this PR's head SHA. + * Called once on mount; no-op on repeat calls. When no providers exist + * (hosted deployment, no local agents) the UI hides the feature. + */ + initSemanticReview = async () => { + if (this.state.semanticProviders !== null) return; + const { owner, repo, pr } = this.state; + const [providers, cached] = await Promise.all([ + fetchSemanticProviders(), + fetchCachedSemanticReview(owner, repo, pr.number, pr.head.sha), + ]); + this.set({ + semanticProviders: providers, + ...(cached + ? { semanticReview: cached, semanticStatus: "done" as const } + : {}), + }); + }; + + startSemanticAnalysis = async (providerId: string) => { + if (this.state.semanticStatus === "running") return; + const { owner, repo, pr, files } = this.state; + this.set({ + semanticStatus: "running", + semanticProgress: [], + semanticError: null, + }); + + try { + const jobId = await apiStartSemanticAnalysis(providerId, { + owner, + repo, + number: pr.number, + headSha: pr.head.sha, + title: pr.title, + body: pr.body ?? "", + files: files.map((f) => ({ + filename: f.filename, + status: f.status, + additions: f.additions, + deletions: f.deletions, + patch: f.patch, + })), + }); + this.semanticJobId = jobId; + this.semanticUnsubscribe = streamSemanticJob(jobId, { + onProgress: (message) => { + this.set({ + semanticProgress: [...this.state.semanticProgress, message], + }); + }, + onDone: ({ review, warnings }) => { + this.semanticJobId = null; + this.semanticUnsubscribe = null; + this.set({ + semanticStatus: "done", + semanticReview: review, + semanticWarnings: warnings ?? [], + }); + }, + onError: (error) => { + this.semanticJobId = null; + this.semanticUnsubscribe = null; + this.set({ semanticStatus: "error", semanticError: error }); + }, + }); + } catch (err) { + this.set({ + semanticStatus: "error", + semanticError: (err as Error).message, + }); + } + }; + + cancelSemanticAnalysis = () => { + const jobId = this.semanticJobId; + this.semanticUnsubscribe?.(); + this.semanticUnsubscribe = null; + this.semanticJobId = null; + if (jobId) void cancelSemanticJob(jobId); + // Keep any previous review; just stop the in-flight run. + this.set({ + semanticStatus: this.state.semanticReview ? "done" : "idle", + semanticProgress: [], + }); + }; + + /** Tear down any live SSE subscription (called when the PR page unmounts). */ + disposeSemantic = () => { + this.semanticUnsubscribe?.(); + this.semanticUnsubscribe = null; + this.semanticJobId = null; + }; + + setViewMode = (mode: "files" | "semantic") => { + if (this.state.viewMode === mode) return; + if (mode === "semantic" && !this.state.semanticReview) return; + const partial: Partial = { viewMode: mode }; + // Entering semantic mode with no selection: select the first layer. + if (mode === "semantic" && !this.state.selectedLayerId) { + const first = this.state.semanticReview?.cohorts[0]; + if (first?.layers[0]) { + partial.selectedLayerId = `${first.id}/${first.layers[0].id}`; + } + } + this.set(partial); + }; + + selectSemanticLayer = (layerKey: string) => { + this.set({ + selectedLayerId: layerKey, + focusedLine: null, + focusedLineSide: null, + selectionAnchor: null, + selectionAnchorSide: null, + }); + }; + + toggleLayerReviewed = (layerKey: string) => { + const next = new Set(this.state.reviewedLayers); + if (next.has(layerKey)) { + next.delete(layerKey); + } else { + next.add(layerKey); + } + try { + localStorage.setItem( + `${this.storageKey}-semantic-layers`, + JSON.stringify([...next]) + ); + } catch {} + this.set({ reviewedLayers: next }); + }; + // --------------------------------------------------------------------------- // Diff Loading Actions // --------------------------------------------------------------------------- diff --git a/src/browser/lib/semantic-client.ts b/src/browser/lib/semantic-client.ts new file mode 100644 index 0000000..e7fa650 --- /dev/null +++ b/src/browser/lib/semantic-client.ts @@ -0,0 +1,117 @@ +/** + * Browser client for the semantic review API (local server only). + * + * All calls degrade gracefully: on the hosted deployment (or any fetch + * failure) the provider list comes back empty and the UI hides the feature. + */ + +import type { SemanticReview } from "@/semantic/schema"; +import type { AnalysisInput, ProviderInfo } from "@/semantic/providers/types"; + +export type { AnalysisInput, ProviderInfo }; + +export async function fetchSemanticProviders(): Promise { + try { + const res = await fetch("/api/semantic/providers"); + if (!res.ok) return []; + const data = (await res.json()) as { providers?: ProviderInfo[] }; + return Array.isArray(data.providers) ? data.providers : []; + } catch { + return []; + } +} + +/** Returns the cached review for this PR + head SHA, or null if none. */ +export async function fetchCachedSemanticReview( + owner: string, + repo: string, + number: number, + headSha: string +): Promise { + try { + const res = await fetch( + `/api/semantic/result/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/${encodeURIComponent(headSha)}` + ); + if (!res.ok) return null; + const data = (await res.json()) as { review?: SemanticReview }; + return data.review ?? null; + } catch { + return null; + } +} + +export async function startSemanticAnalysis( + provider: string, + input: AnalysisInput +): Promise { + const res = await fetch("/api/semantic/analyze", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, input }), + }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { + error?: string; + } | null; + throw new Error(data?.error ?? `analyze failed (${res.status})`); + } + const data = (await res.json()) as { jobId: string }; + return data.jobId; +} + +export interface SemanticJobHandlers { + onProgress: (message: string) => void; + onDone: (result: { review: SemanticReview; warnings?: string[] }) => void; + onError: (error: string) => void; +} + +/** + * Subscribe to a job's SSE progress stream. Returns an unsubscribe function. + * The server replays the full progress log on connect, so late subscribers + * (and reconnects) see everything. + */ +export function streamSemanticJob( + jobId: string, + handlers: SemanticJobHandlers +): () => void { + const es = new EventSource(`/api/semantic/jobs/${jobId}`); + + es.addEventListener("progress", (e) => { + handlers.onProgress((e as MessageEvent).data); + }); + + es.addEventListener("done", (e) => { + es.close(); + try { + handlers.onDone(JSON.parse((e as MessageEvent).data)); + } catch { + handlers.onError("invalid result payload"); + } + }); + + // Note: the server's terminal "error" event carries data; native + // EventSource connection errors fire the same event name without data. + es.addEventListener("error", (e) => { + const data = (e as MessageEvent).data as string | undefined; + if (data !== undefined) { + es.close(); + try { + const parsed = JSON.parse(data) as { error?: string }; + handlers.onError(parsed.error ?? "analysis failed"); + } catch { + handlers.onError("analysis failed"); + } + } else if (es.readyState === EventSource.CLOSED) { + handlers.onError("lost connection to analysis job"); + } + // Otherwise EventSource is reconnecting; the replay will catch us up. + }); + + return () => es.close(); +} + +export async function cancelSemanticJob(jobId: string): Promise { + try { + await fetch(`/api/semantic/jobs/${jobId}`, { method: "DELETE" }); + } catch {} +} From 5e1212f58714cb9c93386c00fed08aafcb1dd259 Mon Sep 17 00:00:00 2001 From: Taylor Kimmett Date: Tue, 25 Aug 2026 16:13:51 -0400 Subject: [PATCH 07/98] feat: add semantic review mode UI (cohort/layer nav, layer bar, keyboard) - SemanticSidebar replaces the file tree in semantic view mode: cohort -> layer tree with reviewed marks, staleness banner, overview, and a layers-reviewed progress footer - SemanticLayerBar above the diff: cohort > layer breadcrumb, markdown summary, clickable range chips that jump to file + line, and a mark-reviewed control - Store: layer navigation/selection, range jumping via the existing selectFile + focusedLine machinery, one-way viewed-file sync when all of a file's ranges sit in reviewed layers - Keyboard: j/k move between layers and v marks a layer reviewed while in semantic mode Co-Authored-By: Claude Opus 5 --- src/browser/components/pr-review.tsx | 27 +- src/browser/components/semantic-panel.tsx | 293 ++++++++++++++++++ src/browser/contexts/pr-review/index.tsx | 120 ++++++- .../pr-review/useKeyboardNavigation.ts | 16 +- 4 files changed, 434 insertions(+), 22 deletions(-) create mode 100644 src/browser/components/semantic-panel.tsx diff --git a/src/browser/components/pr-review.tsx b/src/browser/components/pr-review.tsx index f9dee13..0103846 100644 --- a/src/browser/components/pr-review.tsx +++ b/src/browser/components/pr-review.tsx @@ -48,6 +48,7 @@ import { import { cn } from "../cn"; import { PRHeader } from "./pr-header"; import { SemanticReviewButton } from "./semantic-review-button"; +import { SemanticLayerBar, SemanticSidebar } from "./semantic-panel"; import { FileTree } from "./file-tree"; import { isTestFile } from "@/browser/lib/test-file"; import { FileHeader } from "./file-header"; @@ -323,6 +324,8 @@ function PRReviewLayout() { setMobileSidebarOpen(false); }, []); + const viewMode = usePRReviewSelector((s) => s.viewMode); + // Initialize hooks that load data useKeyboardNavigation(); useHashNavigation(); @@ -440,12 +443,20 @@ function PRReviewLayout() { onClick={() => setMobileSidebarOpen(false)} /> )} - setMobileSidebarOpen(false)} - onFileSelect={handleMobileFileSelect} - /> + {viewMode === "semantic" ? ( + setMobileSidebarOpen(false)} + onLayerSelect={handleMobileFileSelect} + /> + ) : ( + setMobileSidebarOpen(false)} + onFileSelect={handleMobileFileSelect} + /> + )}
@@ -687,6 +698,8 @@ const DiffPanel = memo(function DiffPanel() { const showOverview = usePRReviewSelector((s) => s.showOverview); const diffViewMode = usePRReviewSelector((s) => s.diffViewMode); + const viewMode = usePRReviewSelector((s) => s.viewMode); + const currentFile = useCurrentFile(); const parsedDiff = useCurrentDiff(); const isLoading = useIsCurrentFileLoading(); @@ -709,6 +722,8 @@ const DiffPanel = memo(function DiffPanel() {
+ {viewMode === "semantic" && } + {currentFile ? (
{/* Sticky file header with navigation */} diff --git a/src/browser/components/semantic-panel.tsx b/src/browser/components/semantic-panel.tsx new file mode 100644 index 0000000..20ac377 --- /dev/null +++ b/src/browser/components/semantic-panel.tsx @@ -0,0 +1,293 @@ +import { + Check, + ChevronRight, + CircleCheck, + FileCode, + Layers, + TriangleAlert, + X, +} from "lucide-react"; +import { memo, useMemo } from "react"; +import { cn } from "../cn"; +import { usePRReviewSelector, usePRReviewStore } from "../contexts/pr-review"; +import { Markdown } from "../ui/markdown"; +import type { SemanticLayer, SemanticRange } from "@/semantic/schema"; + +// ============================================================================ +// Semantic Sidebar (replaces the file tree while in semantic view mode) +// ============================================================================ + +interface SemanticSidebarProps { + mobileOpen?: boolean; + onMobileClose?: () => void; + onLayerSelect?: () => void; +} + +export const SemanticSidebar = memo(function SemanticSidebar({ + mobileOpen, + onMobileClose, + onLayerSelect, +}: SemanticSidebarProps) { + const store = usePRReviewStore(); + const review = usePRReviewSelector((s) => s.semanticReview); + const selectedLayerId = usePRReviewSelector((s) => s.selectedLayerId); + const reviewedLayers = usePRReviewSelector((s) => s.reviewedLayers); + const pr = usePRReviewSelector((s) => s.pr); + + const totalLayers = useMemo( + () => review?.cohorts.reduce((n, c) => n + c.layers.length, 0) ?? 0, + [review] + ); + const reviewedCount = useMemo(() => { + if (!review) return 0; + let n = 0; + for (const c of review.cohorts) { + for (const l of c.layers) { + if (reviewedLayers.has(`${c.id}/${l.id}`)) n++; + } + } + return n; + }, [review, reviewedLayers]); + + if (!review) return null; + + const stale = review.headSha !== pr.head.sha; + + return ( + + ); +}); + +// ============================================================================ +// Layer Bar (renders above the diff while a layer is selected) +// ============================================================================ + +export const SemanticLayerBar = memo(function SemanticLayerBar() { + const store = usePRReviewStore(); + const selectedLayerId = usePRReviewSelector((s) => s.selectedLayerId); + const reviewedLayers = usePRReviewSelector((s) => s.reviewedLayers); + const selectedFile = usePRReviewSelector((s) => s.selectedFile); + const warnings = usePRReviewSelector((s) => s.semanticWarnings); + + const found = selectedLayerId + ? store.getSemanticLayer(selectedLayerId) + : null; + if (!found || !selectedLayerId) return null; + + const { cohort, layer } = found; + const isReviewed = reviewedLayers.has(selectedLayerId); + + return ( +
+
+ + {cohort.title} + + + + {layer.title} + + +
+ +
+ {layer.summary} +
+ + + + {warnings.length > 0 && ( +
+ {warnings.length} coverage warning + {warnings.length !== 1 ? "s" : ""} from analysis (see "Uncovered + changes" cohort) +
+ )} +
+ ); +}); + +function RangeChips({ + layer, + selectedFile, +}: { + layer: SemanticLayer; + selectedFile: string | null; +}) { + const store = usePRReviewStore(); + + const chips = useMemo(() => { + const byFile = new Map(); + for (const range of layer.ranges) { + const existing = byFile.get(range.file) ?? []; + existing.push(range); + byFile.set(range.file, existing); + } + return [...byFile.entries()]; + }, [layer]); + + return ( +
+ {chips.map(([file, ranges]) => + ranges.map((range, i) => { + const name = file.split("/").pop() ?? file; + const isCurrent = selectedFile === file; + return ( + + ); + }) + )} +
+ ); +} diff --git a/src/browser/contexts/pr-review/index.tsx b/src/browser/contexts/pr-review/index.tsx index 909fe79..333c80b 100644 --- a/src/browser/contexts/pr-review/index.tsx +++ b/src/browser/contexts/pr-review/index.tsx @@ -26,7 +26,12 @@ import { streamSemanticJob, type ProviderInfo, } from "@/browser/lib/semantic-client"; -import type { SemanticReview } from "@/semantic/schema"; +import type { + SemanticCohort, + SemanticLayer, + SemanticRange, + SemanticReview, +} from "@/semantic/schema"; import { type GitHubStore, type Review, @@ -851,27 +856,72 @@ export class PRReviewStore { setViewMode = (mode: "files" | "semantic") => { if (this.state.viewMode === mode) return; if (mode === "semantic" && !this.state.semanticReview) return; - const partial: Partial = { viewMode: mode }; - // Entering semantic mode with no selection: select the first layer. + this.set({ viewMode: mode }); + // Entering semantic mode with no selection: open the first layer. if (mode === "semantic" && !this.state.selectedLayerId) { - const first = this.state.semanticReview?.cohorts[0]; - if (first?.layers[0]) { - partial.selectedLayerId = `${first.id}/${first.layers[0].id}`; - } + const keys = this.semanticLayerKeys(); + if (keys.length > 0) this.selectSemanticLayer(keys[0]); } - this.set(partial); + }; + + /** Ordered "cohortId/layerId" keys across the whole review. */ + private semanticLayerKeys(): string[] { + const review = this.state.semanticReview; + if (!review) return []; + return review.cohorts.flatMap((c) => + c.layers.map((l) => `${c.id}/${l.id}`) + ); + } + + getSemanticLayer = ( + layerKey: string + ): { cohort: SemanticCohort; layer: SemanticLayer } | null => { + const review = this.state.semanticReview; + if (!review) return null; + const slash = layerKey.indexOf("/"); + const cohort = review.cohorts.find( + (c) => c.id === layerKey.slice(0, slash) + ); + const layer = cohort?.layers.find( + (l) => l.id === layerKey.slice(slash + 1) + ); + return cohort && layer ? { cohort, layer } : null; }; selectSemanticLayer = (layerKey: string) => { + const found = this.getSemanticLayer(layerKey); + if (!found) return; + this.set({ selectedLayerId: layerKey }); + const range = found.layer.ranges[0]; + if (range) this.jumpToSemanticRange(range); + }; + + /** Open the range's file and focus its first line (diff auto-scrolls). */ + jumpToSemanticRange = (range: SemanticRange) => { + if (!this.state.files.some((f) => f.filename === range.file)) return; + this.selectFile(range.file); this.set({ - selectedLayerId: layerKey, - focusedLine: null, - focusedLineSide: null, - selectionAnchor: null, - selectionAnchorSide: null, + focusedLine: range.startLine, + focusedLineSide: range.side === "old" ? "old" : "new", }); }; + /** j/k in semantic mode: move between layers (wraps around). */ + navigateSemanticLayer = (direction: "next" | "prev") => { + const keys = this.semanticLayerKeys(); + if (keys.length === 0) return; + const idx = this.state.selectedLayerId + ? keys.indexOf(this.state.selectedLayerId) + : -1; + const next = + idx === -1 + ? direction === "next" + ? 0 + : keys.length - 1 + : (idx + (direction === "next" ? 1 : -1) + keys.length) % keys.length; + this.selectSemanticLayer(keys[next]); + }; + toggleLayerReviewed = (layerKey: string) => { const next = new Set(this.state.reviewedLayers); if (next.has(layerKey)) { @@ -886,8 +936,52 @@ export class PRReviewStore { ); } catch {} this.set({ reviewedLayers: next }); + this.syncViewedFromLayers(next); }; + /** + * A file whose every range sits inside reviewed layers is marked viewed in + * the normal file view. One-way: un-reviewing a layer never un-views a file. + */ + private syncViewedFromLayers(reviewedLayers: Set) { + const review = this.state.semanticReview; + if (!review) return; + const layersByFile = new Map>(); + for (const cohort of review.cohorts) { + for (const layer of cohort.layers) { + const key = `${cohort.id}/${layer.id}`; + for (const range of layer.ranges) { + let set = layersByFile.get(range.file); + if (!set) { + set = new Set(); + layersByFile.set(range.file, set); + } + set.add(key); + } + } + } + const viewed = new Set(this.state.viewedFiles); + let changed = false; + for (const [file, keys] of layersByFile) { + if (viewed.has(file)) continue; + let all = true; + for (const key of keys) { + if (!reviewedLayers.has(key)) { + all = false; + break; + } + } + if (all) { + viewed.add(file); + changed = true; + } + } + if (changed) { + this.persistViewedFiles(viewed); + this.set({ viewedFiles: viewed }); + } + } + // --------------------------------------------------------------------------- // Diff Loading Actions // --------------------------------------------------------------------------- diff --git a/src/browser/contexts/pr-review/useKeyboardNavigation.ts b/src/browser/contexts/pr-review/useKeyboardNavigation.ts index 6ca0a22..4d8ceda 100644 --- a/src/browser/contexts/pr-review/useKeyboardNavigation.ts +++ b/src/browser/contexts/pr-review/useKeyboardNavigation.ts @@ -106,19 +106,29 @@ export function useKeyboardNavigation() { e.preventDefault(); // Use startTransition to allow React to interrupt rendering during rapid navigation startTransition(() => { - store.navigateToPrevUnviewedFile(); + if (state.viewMode === "semantic") { + store.navigateSemanticLayer("prev"); + } else { + store.navigateToPrevUnviewedFile(); + } }); break; case "k": e.preventDefault(); // Use startTransition to allow React to interrupt rendering during rapid navigation startTransition(() => { - store.navigateToNextUnviewedFile(); + if (state.viewMode === "semantic") { + store.navigateSemanticLayer("next"); + } else { + store.navigateToNextUnviewedFile(); + } }); break; case "v": e.preventDefault(); - if (state.selectedFiles.size > 0) { + if (state.viewMode === "semantic" && state.selectedLayerId) { + store.toggleLayerReviewed(state.selectedLayerId); + } else if (state.selectedFiles.size > 0) { store.toggleViewedMultiple([...state.selectedFiles]); } else if (state.selectedFile) { store.toggleViewed(state.selectedFile); From 9df8a861e297f82579b41ad6f3cdb4ebeeb9af68 Mon Sep 17 00:00:00 2001 From: Taylor Kimmett Date: Tue, 25 Aug 2026 16:16:13 -0400 Subject: [PATCH 08/98] feat: add lazy-loaded mermaid diagrams to semantic layers - MermaidDiagram component: dynamic import of mermaid (dark theme, strict security), raw-source code block fallback on invalid syntax - Collapsed-by-default 'Show diagram' toggle in the layer bar so the library only loads when a diagram is actually opened - Enable code splitting in the browser build; mermaid lands in lazy chunks and the main bundle stays at its previous size Co-Authored-By: Claude Opus 5 --- bun.lock | 223 +++++++++++++++++++++ package.json | 1 + scripts/build-browser.ts | 3 + src/browser/components/mermaid-diagram.tsx | 72 +++++++ src/browser/components/semantic-panel.tsx | 35 +++- 5 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 src/browser/components/mermaid-diagram.tsx diff --git a/bun.lock b/bun.lock index 64e9a22..46e7292 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-tabs": "^1.1.13", + "mermaid": "^11.17.2", "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", @@ -65,6 +66,8 @@ "packages": { "7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="], + "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.245", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.245", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.245", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.245", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.245", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.245", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.245", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.245", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.245" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-b/SXCxBxZfN4ItHFDUS1uJ3xhI5fOSv3/VxyZvekYmlsbSwZi/75UqKhVlT7qbB1LDJOB48ZmQdCTxWhJWjObA=="], "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.245", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oH1R4yxVKR8oSYMqKHb5NaAPYq8+/enKR0qZKi+lKm6ru64onCmoujT3ilD9rz6TYALCYp2L8Jl2zwOerKSpug=="], @@ -87,6 +90,10 @@ "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], + + "@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], + "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], "@electron/asar": ["@electron/asar@4.0.1", "", { "dependencies": { "commander": "^13.1.0", "glob": "^11.0.1", "minimatch": "^10.0.1" }, "bin": { "asar": "bin/asar.mjs" } }, "sha512-F4Ykm1jiBGY1WV/o8Q8oFW8Nq0u+S2/vPujzNJtdSJ6C4LHC4CiGLn7c17s7SolZ23gcvCebMncmZtNc+MkxPQ=="], @@ -173,6 +180,10 @@ "@hono/node-server": ["@hono/node-server@1.19.6", "", { "peerDependencies": { "hono": "^4" } }, "sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw=="], + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + + "@iconify/utils": ["@iconify/utils@3.1.4", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw=="], + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -233,6 +244,8 @@ "@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="], + "@mermaid-js/parser": ["@mermaid-js/parser@1.2.1", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], "@next/env": ["@next/env@16.0.6", "", {}, "sha512-PFTK/G/vM3UJwK5XDYMFOqt8QW42mmhSgdKDapOlCqBUAOfJN2dyOnASR/xUR/JRrro0pLohh/zOJ77xUQWQAg=="], @@ -391,6 +404,68 @@ "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], + + "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], + + "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + + "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], + + "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], + + "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + + "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], + + "@types/d3-geo": ["@types/d3-geo@3.1.1", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w=="], + + "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], + + "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], + + "@types/d3-random": ["@types/d3-random@3.0.4", "", {}, "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + + "@types/d3-shape": ["@types/d3-shape@3.2.0", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -399,6 +474,8 @@ "@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], "@types/http-cache-semantics": ["@types/http-cache-semantics@4.0.4", "", {}, "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA=="], @@ -421,6 +498,8 @@ "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="], @@ -445,6 +524,8 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], @@ -605,6 +686,8 @@ "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], + "crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="], "cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="], @@ -613,6 +696,80 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "cytoscape": ["cytoscape@3.34.2", "", {}, "sha512-Cm2jaj1X/PBNlzV9yH8zcfGOxO7U+CJ/+mxSBVPSchLaugdp4jtlGx5qaHtPRZ6tgiZ5P+o1XoRfJA+ba6KM3g=="], + + "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], + + "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="], + + "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], + + "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], + + "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], + + "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], + + "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], + + "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], + + "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], + + "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], + + "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + + "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], + + "dayjs": ["dayjs@1.11.23", "", {}, "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], @@ -627,6 +784,8 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], @@ -649,6 +808,8 @@ "dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="], + "dompurify": ["dompurify@3.4.14", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], @@ -695,6 +856,8 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "es-toolkit": ["es-toolkit@1.51.0", "", {}, "sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw=="], + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], "esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], @@ -735,6 +898,8 @@ "fast-uri": ["fast-uri@3.1.6", "", {}, "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q=="], + "fastdom": ["fastdom@1.0.12", "", { "dependencies": { "strictdom": "^1.0.1" } }, "sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], @@ -791,6 +956,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], @@ -849,6 +1016,8 @@ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], @@ -861,6 +1030,8 @@ "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -913,12 +1084,20 @@ "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], + "katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], + + "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], + "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="], @@ -939,6 +1118,8 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -977,6 +1158,8 @@ "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "mermaid": ["mermaid@11.17.2", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.21", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "fastdom": "1.0.12", "katex": "^0.16.47", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg=="], + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], @@ -1103,12 +1286,16 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="], + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -1129,6 +1316,10 @@ "png2icons": ["png2icons@2.0.1", "", { "bin": { "png2icons": "./png2icons-cli.js" } }, "sha512-GDEQJr8OG4e6JMp7mABtXFSEpgJa1CCpbQiAR+EjhkHJHnUL9zPPtbOrjsMD8gUbikgv3j7x404b0YJsV3aVFA=="], + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], + + "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], + "postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "posthog-js": ["posthog-js@1.300.0", "", { "dependencies": { "@posthog/core": "1.7.0", "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", "web-vitals": "^4.2.4" } }, "sha512-FpeD4pkpg9i/3F5xpeX+CZeq3R3BaLyFDkzb4sOjpUIW1AAVuOEqPTxnWIess6C3pOw1u3mowq0WSeh1PmrqfA=="], @@ -1221,8 +1412,14 @@ "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], + + "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -1295,6 +1492,8 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "strictdom": ["strictdom@1.0.1", "", {}, "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -1313,6 +1512,8 @@ "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], + "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], @@ -1331,6 +1532,8 @@ "tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="], + "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], "tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="], @@ -1347,6 +1550,8 @@ "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "ts-dedent": ["ts-dedent@2.3.0", "", {}, "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], @@ -1395,6 +1600,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "uuid": ["uuid@14.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], @@ -1509,6 +1716,14 @@ "config-file-ts/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], + + "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + + "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "dir-compare/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "dmg-license/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], @@ -1529,6 +1744,8 @@ "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + "make-fetch-happen/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="], "make-fetch-happen/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], @@ -1621,6 +1838,12 @@ "config-file-ts/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + "dmg-license/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "electron-winstaller/@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], diff --git a/package.json b/package.json index 3e87b44..77729fb 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-tabs": "^1.1.13", + "mermaid": "^11.17.2", "rehype-highlight": "^7.0.2", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", diff --git a/scripts/build-browser.ts b/scripts/build-browser.ts index bce13fa..30a873a 100644 --- a/scripts/build-browser.ts +++ b/scripts/build-browser.ts @@ -13,6 +13,9 @@ async function build() { plugins: [tailwind], target: "browser", format: "esm", + // Keep dynamic imports (e.g. mermaid for semantic review diagrams) out + // of the main bundle. + splitting: true, }); if (!mainResult.success) { diff --git a/src/browser/components/mermaid-diagram.tsx b/src/browser/components/mermaid-diagram.tsx new file mode 100644 index 0000000..db57c85 --- /dev/null +++ b/src/browser/components/mermaid-diagram.tsx @@ -0,0 +1,72 @@ +import { memo, useEffect, useRef, useState } from "react"; + +let mermaidInit: Promise | null = null; + +/** Lazy-load and initialize mermaid once (heavy dependency, ~1MB). */ +function loadMermaid() { + if (!mermaidInit) { + mermaidInit = import("mermaid").then((m) => { + m.default.initialize({ + startOnLoad: false, + theme: "dark", + securityLevel: "strict", + fontFamily: "inherit", + }); + return m.default; + }); + } + return mermaidInit; +} + +let diagramCounter = 0; + +interface MermaidDiagramProps { + /** Mermaid source text. */ + source: string; +} + +/** + * Renders a mermaid diagram. On invalid syntax (LLM output isn't guaranteed + * valid mermaid) it falls back to showing the raw source in a code block — + * never a broken half-view. + */ +export const MermaidDiagram = memo(function MermaidDiagram({ + source, +}: MermaidDiagramProps) { + const containerRef = useRef(null); + const [error, setError] = useState(false); + + useEffect(() => { + let cancelled = false; + setError(false); + loadMermaid() + .then(async (mermaid) => { + const id = `semantic-diagram-${diagramCounter++}`; + const { svg } = await mermaid.render(id, source); + if (!cancelled && containerRef.current) { + containerRef.current.innerHTML = svg; + } + }) + .catch(() => { + if (!cancelled) setError(true); + }); + return () => { + cancelled = true; + }; + }, [source]); + + if (error) { + return ( +
+        {source}
+      
+ ); + } + + return ( +
+ ); +}); diff --git a/src/browser/components/semantic-panel.tsx b/src/browser/components/semantic-panel.tsx index 20ac377..779ff39 100644 --- a/src/browser/components/semantic-panel.tsx +++ b/src/browser/components/semantic-panel.tsx @@ -7,10 +7,11 @@ import { TriangleAlert, X, } from "lucide-react"; -import { memo, useMemo } from "react"; +import { memo, useEffect, useMemo, useState } from "react"; import { cn } from "../cn"; import { usePRReviewSelector, usePRReviewStore } from "../contexts/pr-review"; import { Markdown } from "../ui/markdown"; +import { MermaidDiagram } from "./mermaid-diagram"; import type { SemanticLayer, SemanticRange } from "@/semantic/schema"; // ============================================================================ @@ -231,6 +232,14 @@ export const SemanticLayerBar = memo(function SemanticLayerBar() { + {layer.diagram && ( + + )} + {warnings.length > 0 && (
{warnings.length} coverage warning @@ -242,6 +251,30 @@ export const SemanticLayerBar = memo(function SemanticLayerBar() { ); }); +/** Collapsed-by-default diagram; mermaid only loads when first expanded. */ +function LayerDiagram({ mermaid, kind }: { mermaid: string; kind: string }) { + const [open, setOpen] = useState(false); + + // Collapse again when the layer changes (component is keyed by layer). + useEffect(() => setOpen(false), [mermaid]); + + return ( +
+ + {open && ( +
+ +
+ )} +
+ ); +} + function RangeChips({ layer, selectedFile, From 89078c543ff0926a31a7212637d7ff96b9ac3b3b Mon Sep 17 00:00:00 2001 From: Taylor Kimmett Date: Tue, 25 Aug 2026 16:31:14 -0400 Subject: [PATCH 09/98] fix: black screen from wrong entry chunk in split builds With splitting enabled, Bun's HTML entrypoint rewriting can point the + diff --git a/src/browser/index.tsx b/src/browser/index.tsx index 607b443..c8a79fc 100644 --- a/src/browser/index.tsx +++ b/src/browser/index.tsx @@ -7,8 +7,13 @@ import { TabProvider } from "./contexts/tabs"; import { CommandPaletteProvider } from "./components/command-palette"; import { AppShell } from "./components/app-shell"; import { WelcomeDialog } from "./components/welcome-dialog"; +import { getTheme } from "./lib/theme"; import "./index.css"; +// Re-applies the persisted theme (index.html already set it pre-paint) and +// starts listening for system colour-scheme changes. +getTheme(); + createRoot(document.getElementById("app")!).render( diff --git a/src/browser/lib/theme.test.ts b/src/browser/lib/theme.test.ts new file mode 100644 index 0000000..e439eb6 --- /dev/null +++ b/src/browser/lib/theme.test.ts @@ -0,0 +1,23 @@ +import { test, expect } from "bun:test"; +import { isTheme, nextTheme, resolveTheme } from "./theme"; + +test("isTheme accepts only the three theme values", () => { + expect(isTheme("light")).toBe(true); + expect(isTheme("dark")).toBe(true); + expect(isTheme("system")).toBe(true); + expect(isTheme("Dark")).toBe(false); + expect(isTheme(null)).toBe(false); + expect(isTheme(undefined)).toBe(false); + expect(isTheme("")).toBe(false); +}); + +test("nextTheme cycles light -> dark -> system -> light", () => { + expect(nextTheme("light")).toBe("dark"); + expect(nextTheme("dark")).toBe("system"); + expect(nextTheme("system")).toBe("light"); +}); + +test("resolveTheme passes explicit themes through unchanged", () => { + expect(resolveTheme("light")).toBe("light"); + expect(resolveTheme("dark")).toBe("dark"); +}); diff --git a/src/browser/lib/theme.ts b/src/browser/lib/theme.ts new file mode 100644 index 0000000..9922fc1 --- /dev/null +++ b/src/browser/lib/theme.ts @@ -0,0 +1,136 @@ +// ============================================================================ +// Theme (light / dark / system) +// ============================================================================ +// +// App-global preference persisted in localStorage. The `dark` class on +// is what Tailwind's `dark:` variant keys off of (see index.css: +// `@custom-variant dark (&:is(.dark *))`). +// +// The initial class is applied by an inline script in index.html so there is +// no flash of the wrong theme before React mounts. This module keeps that in +// sync afterwards. + +import { useSyncExternalStore } from "react"; + +export type Theme = "light" | "dark" | "system"; + +export const THEME_STORAGE_KEY = "pulldash_theme"; + +export function isTheme(value: unknown): value is Theme { + return value === "light" || value === "dark" || value === "system"; +} + +export function getStoredTheme(): Theme { + try { + const stored = localStorage.getItem(THEME_STORAGE_KEY); + if (isTheme(stored)) return stored; + } catch {} + return "system"; +} + +function setStoredTheme(theme: Theme): void { + try { + localStorage.setItem(THEME_STORAGE_KEY, theme); + } catch {} +} + +function systemPrefersDark(): boolean { + try { + return window.matchMedia("(prefers-color-scheme: dark)").matches; + } catch {} + return true; +} + +/** The theme actually being rendered, resolving "system". */ +export function resolveTheme(theme: Theme): "light" | "dark" { + if (theme === "system") return systemPrefersDark() ? "dark" : "light"; + return theme; +} + +export function applyTheme(theme: Theme): void { + const root = document.documentElement; + root.classList.toggle("dark", resolveTheme(theme) === "dark"); + root.style.colorScheme = resolveTheme(theme); +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +type Listener = () => void; + +let current: Theme = "system"; +const listeners = new Set(); +let initialized = false; +let mediaQuery: MediaQueryList | null = null; + +function emit(): void { + for (const listener of listeners) listener(); +} + +function handleSystemChange(): void { + if (current === "system") applyTheme(current); +} + +function init(): void { + if (initialized) return; + initialized = true; + current = getStoredTheme(); + applyTheme(current); + try { + mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + mediaQuery.addEventListener("change", handleSystemChange); + } catch {} +} + +export function getTheme(): Theme { + init(); + return current; +} + +export function setTheme(theme: Theme): void { + init(); + if (current === theme) return; + current = theme; + setStoredTheme(theme); + applyTheme(theme); + emit(); +} + +/** Cycles light -> dark -> system -> light. */ +export function nextTheme(theme: Theme): Theme { + if (theme === "light") return "dark"; + if (theme === "dark") return "system"; + return "light"; +} + +export function subscribeTheme(listener: Listener): () => void { + init(); + listeners.add(listener); + const onSystem = () => { + if (current === "system") listener(); + }; + mediaQuery?.addEventListener("change", onSystem); + return () => { + listeners.delete(listener); + mediaQuery?.removeEventListener("change", onSystem); + }; +} + +// --------------------------------------------------------------------------- +// React bindings +// --------------------------------------------------------------------------- + +/** The user's preference, including "system". */ +export function useTheme(): Theme { + return useSyncExternalStore(subscribeTheme, getTheme, () => "system"); +} + +/** The theme actually rendered — "system" already resolved. */ +export function useResolvedTheme(): "light" | "dark" { + return useSyncExternalStore( + subscribeTheme, + () => resolveTheme(getTheme()), + () => "dark" + ); +} diff --git a/src/browser/ui/diff/theme.css b/src/browser/ui/diff/theme.css index 5d9d834..0796af5 100644 --- a/src/browser/ui/diff/theme.css +++ b/src/browser/ui/diff/theme.css @@ -906,15 +906,10 @@ } tr[data-comment-highlight="true"] { - @apply !bg-[var(--color-yellow)]/20; + background-color: var(--diff-comment-highlight-bg) !important; transition: background-color 150ms ease; } -.dark tr[data-comment-highlight="true"] { - @apply !bg-[var(--color-yellow)]/20; - background-color: rgba(188, 133, 255, 0.22); -} - tr[data-comment-draft="true"] { background-color: rgba(59, 130, 246, 0.12); } From a4b7933433b0d64701aa2105832ac8598a940778 Mon Sep 17 00:00:00 2001 From: Taylor Kimmett Date: Tue, 25 Aug 2026 17:56:51 -0400 Subject: [PATCH 18/98] feat: collapse and expand inline diff comments Adds a global collapse-all toggle in the diff file header plus a per-thread collapse control. Collapse state is modeled as a persisted global default with in-memory per-thread overrides, so collapse-all and manual per-thread toggles compose. Collapsed threads render a one-line stub (author + preview) and auto-expand while focused, edited, or being replied to so navigation and unsaved input are never hidden. Co-Authored-By: Claude Opus 5 --- src/browser/components/file-header.tsx | 52 +++++++ src/browser/components/pr-review.tsx | 156 ++++++++++++++----- src/browser/contexts/pr-review/index.test.ts | 90 ++++++++++- src/browser/contexts/pr-review/index.tsx | 95 +++++++++++ 4 files changed, 351 insertions(+), 42 deletions(-) diff --git a/src/browser/components/file-header.tsx b/src/browser/components/file-header.tsx index 28dc51f..97da332 100644 --- a/src/browser/components/file-header.tsx +++ b/src/browser/components/file-header.tsx @@ -5,7 +5,15 @@ import { ChevronRight, Columns2, AlignJustify, + MessagesSquare, + MessageSquareOff, } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../ui/tooltip"; import { cn } from "../cn"; import { Keycap } from "../ui/keycap"; import type { PullRequestFile } from "@/api/types"; @@ -22,6 +30,9 @@ interface FileHeaderProps { onNextFile?: () => void; diffViewMode?: DiffViewMode; onToggleDiffViewMode?: () => void; + commentCount?: number; + allCommentsCollapsed?: boolean; + onToggleAllComments?: () => void; } export const FileHeader = memo(function FileHeader({ @@ -34,6 +45,9 @@ export const FileHeader = memo(function FileHeader({ onNextFile, diffViewMode, onToggleDiffViewMode, + commentCount, + allCommentsCollapsed, + onToggleAllComments, }: FileHeaderProps) { const fileStatusBadge = (() => { switch (file.status) { @@ -105,6 +119,44 @@ export const FileHeader = memo(function FileHeader({
+ {/* Collapse/expand all inline comments */} + {onToggleAllComments && ( + + + + + + + {allCommentsCollapsed + ? "Expand all comments" + : "Collapse all comments"} + + + + )} + {/* Split/Unified toggle */} {onToggleDiffViewMode && (
diff --git a/src/browser/components/pr-review.tsx b/src/browser/components/pr-review.tsx index 55d5620..bd3d6f3 100644 --- a/src/browser/components/pr-review.tsx +++ b/src/browser/components/pr-review.tsx @@ -87,6 +87,8 @@ import { useCommentingRange, useCommentRangeLookup, getTimeAgo, + commentThreadKey, + isThreadCollapsed, type LocalPendingComment, type ParsedDiff, type DiffLine, @@ -697,6 +699,11 @@ const DiffPanel = memo(function DiffPanel() { const selectedFiles = usePRReviewSelector((s) => s.selectedFiles); const showOverview = usePRReviewSelector((s) => s.showOverview); const diffViewMode = usePRReviewSelector((s) => s.diffViewMode); + const allCommentsCollapsed = usePRReviewSelector( + (s) => s.allCommentsCollapsed + ); + const currentFileComments = useCurrentFileComments(); + const currentFileCommentCount = currentFileComments.length; const viewMode = usePRReviewSelector((s) => s.viewMode); @@ -739,6 +746,9 @@ const DiffPanel = memo(function DiffPanel() { onNextFile={() => store.navigateToNextUnviewedFile()} diffViewMode={diffViewMode} onToggleDiffViewMode={() => store.toggleDiffViewMode()} + commentCount={currentFileCommentCount} + allCommentsCollapsed={allCommentsCollapsed} + onToggleAllComments={() => store.toggleAllCommentsCollapsed()} />
@@ -1077,6 +1087,12 @@ const DiffViewer = memo(function DiffViewer({ (s) => s.editingPendingCommentId ); const replyingToCommentId = usePRReviewSelector((s) => s.replyingToCommentId); + const allCommentsCollapsed = usePRReviewSelector( + (s) => s.allCommentsCollapsed + ); + const collapsedThreadOverrides = usePRReviewSelector( + (s) => s.collapsedThreadOverrides + ); // Helper to get expanded lines for a skip block const getExpandedLines = useCallback( @@ -1432,13 +1448,21 @@ const DiffViewer = memo(function DiffViewer({ return 180; case "pending-comment": return 100; - case "comment-thread": - return 80 + row.comments.length * 60; + case "comment-thread": { + // Collapsed threads render as a single-line stub. + const first = row.comments[0]; + const collapsed = isThreadCollapsed( + store.getSnapshot(), + commentThreadKey(row.comments), + first?.is_resolved ?? false + ); + return collapsed ? 40 : 80 + row.comments.length * 60; + } default: return 20; } }, - [virtualRows] + [virtualRows, store, allCommentsCollapsed, collapsedThreadOverrides] ); const virtualizer = useVirtualizer({ @@ -2550,6 +2574,21 @@ const InlineCommentForm = memo(function InlineCommentForm({ // Comment Thread // ============================================================================ +/** Collapse a comment body to a short single-line preview. */ +function summarizeCommentBody( + body: string | undefined, + maxLength = 80 +): string { + if (!body) return ""; + const flat = body + .replace(/```[\s\S]*?```/g, " ") + .replace(/[#>*_`~-]/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (flat.length <= maxLength) return flat; + return `${flat.slice(0, maxLength).trimEnd()}…`; +} + interface CommentThreadProps { comments: ReviewComment[]; focusedCommentId: number | null; @@ -2571,7 +2610,6 @@ const CommentThread = memo(function CommentThread({ const { resolveThread, unresolveThread } = useThreadActions(); const [replyText, setReplyText] = useState(""); const [submitting, setSubmitting] = useState(false); - const [isCollapsed, setIsCollapsed] = useState(false); const [resolving, setResolving] = useState(false); const replyingTo = @@ -2582,6 +2620,38 @@ const CommentThread = memo(function CommentThread({ const isResolved = firstComment?.is_resolved ?? false; const threadId = firstComment?.pull_request_review_thread_id; + // Collapse state lives in the store: a global default plus per-thread overrides. + const threadKey = commentThreadKey(comments); + const allCommentsCollapsed = usePRReviewSelector( + (s) => s.allCommentsCollapsed + ); + const collapsedThreadOverrides = usePRReviewSelector( + (s) => s.collapsedThreadOverrides + ); + const isCollapsed = isThreadCollapsed( + { allCommentsCollapsed, collapsedThreadOverrides }, + threadKey, + isResolved + ); + // Never collapse a thread the reviewer is actively replying to (unsaved + // input), editing in, or navigating to via the keyboard. + const forceExpanded = + replyingTo !== null || + comments.some( + (c) => c.id === focusedCommentId || c.id === editingCommentId + ); + const collapsed = isCollapsed && !forceExpanded; + + const toggleCollapsed = useCallback(() => { + store.toggleThreadCollapsed(threadKey, isResolved); + }, [store, threadKey, isResolved]); + + // One-line preview shown while collapsed so context isn't lost. + const summary = useMemo( + () => summarizeCommentBody(firstComment?.body), + [firstComment?.body] + ); + const handleSubmitReply = useCallback(async () => { if (!replyText.trim() || !replyingTo) return; @@ -2619,30 +2689,23 @@ const CommentThread = memo(function CommentThread({ setResolving(true); try { await resolveThread(threadId); - // Auto-collapse when resolved - setIsCollapsed(true); + // Auto-collapse when resolved (clears any manual override) + store.setThreadCollapsed(threadKey, true, true); } finally { setResolving(false); } - }, [threadId, resolveThread]); + }, [threadId, resolveThread, store, threadKey]); const handleUnresolve = useCallback(async () => { if (!threadId) return; setResolving(true); try { await unresolveThread(threadId); - setIsCollapsed(false); + store.setThreadCollapsed(threadKey, false, false); } finally { setResolving(false); } - }, [threadId, unresolveThread]); - - // Auto-collapse resolved threads - useEffect(() => { - if (isResolved) { - setIsCollapsed(true); - } - }, [isResolved]); + }, [threadId, unresolveThread, store, threadKey]); return (
- {/* Thread header with resolve/unresolve */} -
-
+ {/* Thread header with resolve/unresolve + collapse */} +
+
-
+ +
{canWrite && threadId && ( - )} +
- {/* Comment content (collapsible for resolved) */} - {!isCollapsed && ( + {/* Comment content (collapsible) */} + {!collapsed && ( <> {comments.map((comment, idx) => ( { expect(store.getSnapshot().selectedFile).toBe("README.md"); expect(store.getSnapshot().selectedLayerId).toBe("c1/l1"); }); + +// ============================================================================ +// Comment Collapse +// ============================================================================ + +test("commentThreadKey prefers the review thread id over the root comment id", () => { + const rooted = createMockComment(1, "src/index.ts", 10); + expect(commentThreadKey([rooted])).toBe("c1"); + + const threaded = { + ...rooted, + pull_request_review_thread_id: "PRRT_abc", + } as ReviewComment; + expect( + commentThreadKey([threaded, createMockComment(2, "src/index.ts", 10)]) + ).toBe("PRRT_abc"); +}); + +test("toggleAllCommentsCollapsed flips the global default and persists it", () => { + const store = createStore(); + + expect(store.getSnapshot().allCommentsCollapsed).toBe(false); + + store.toggleAllCommentsCollapsed(); + + expect(store.getSnapshot().allCommentsCollapsed).toBe(true); + expect(storage.get("pulldash_collapse_all_comments")).toBe("true"); + + // A fresh store picks up the persisted preference. + expect(createStore().getSnapshot().allCommentsCollapsed).toBe(true); +}); + +test("toggleThreadCollapsed overrides the global default per thread", () => { + const store = createStore(); + + store.toggleThreadCollapsed("t1"); + expect(store.isThreadCollapsed("t1")).toBe(true); + expect(store.isThreadCollapsed("t2")).toBe(false); + + store.toggleThreadCollapsed("t1"); + expect(store.isThreadCollapsed("t1")).toBe(false); + // Returning to the default drops the override entirely. + expect(store.getSnapshot().collapsedThreadOverrides.size).toBe(0); +}); + +test("resolved threads collapse by default but stay expandable", () => { + const store = createStore(); + + expect(store.isThreadCollapsed("t1", true)).toBe(true); + + store.toggleThreadCollapsed("t1", true); + + expect(store.isThreadCollapsed("t1", true)).toBe(false); + expect(store.getSnapshot().collapsedThreadOverrides.get("t1")).toBe(false); +}); + +test("collapse-all clears per-thread overrides", () => { + const store = createStore(); + + store.toggleThreadCollapsed("t1"); + expect(store.getSnapshot().collapsedThreadOverrides.size).toBe(1); + + store.setAllCommentsCollapsed(true); + + expect(store.getSnapshot().collapsedThreadOverrides.size).toBe(0); + expect(store.isThreadCollapsed("t1")).toBe(true); + expect(store.isThreadCollapsed("t2")).toBe(true); + + // Expanding a single thread while the global default is collapsed works. + store.toggleThreadCollapsed("t2"); + expect(store.isThreadCollapsed("t2")).toBe(false); + expect(store.isThreadCollapsed("t1")).toBe(true); +}); + +test("isThreadCollapsed reads state without the store instance", () => { + const store = createStore(); + store.setAllCommentsCollapsed(true); + store.toggleThreadCollapsed("t1"); + + const state = store.getSnapshot(); + expect(isThreadCollapsed(state, "t1", false)).toBe(false); + expect(isThreadCollapsed(state, "other", false)).toBe(true); +}); diff --git a/src/browser/contexts/pr-review/index.tsx b/src/browser/contexts/pr-review/index.tsx index c85e0ba..2449a4f 100644 --- a/src/browser/contexts/pr-review/index.tsx +++ b/src/browser/contexts/pr-review/index.tsx @@ -250,6 +250,11 @@ interface PRReviewState { focusedPendingCommentId: string | null; editingPendingCommentId: string | null; + // Comment collapse: global default (persisted) + per-thread in-memory overrides. + // Effective collapsed = override ?? (allCommentsCollapsed || threadIsResolved) + allCommentsCollapsed: boolean; + collapsedThreadOverrides: Map; + // Semantic review (local-agent-powered; hidden when no providers) semanticProviders: ProviderInfo[] | null; // null = not yet loaded semanticStatus: "idle" | "running" | "done" | "error"; @@ -310,6 +315,48 @@ function setStoredHideTestFiles(hide: boolean): void { } catch {} } +// Global storage key for collapsing all inline comments (user preference) +const COLLAPSE_ALL_COMMENTS_KEY = "pulldash_collapse_all_comments"; + +function getStoredCollapseAllComments(): boolean { + try { + return localStorage.getItem(COLLAPSE_ALL_COMMENTS_KEY) === "true"; + } catch {} + return false; +} + +function setStoredCollapseAllComments(collapsed: boolean): void { + try { + localStorage.setItem(COLLAPSE_ALL_COMMENTS_KEY, String(collapsed)); + } catch {} +} + +/** + * Stable key for a comment thread: the review thread id when available, + * otherwise the id of the first (root) comment in the thread. + */ +export function commentThreadKey( + comments: { id: number; pull_request_review_thread_id?: string | null }[] +): string { + const first = comments[0]; + if (!first) return ""; + return first.pull_request_review_thread_id ?? `c${first.id}`; +} + +/** Effective collapsed state for a thread given store state. */ +export function isThreadCollapsed( + state: Pick< + PRReviewState, + "allCommentsCollapsed" | "collapsedThreadOverrides" + >, + key: string, + isResolved: boolean +): boolean { + const override = state.collapsedThreadOverrides.get(key); + if (override !== undefined) return override; + return state.allCommentsCollapsed || isResolved; +} + export class PRReviewStore { private state: PRReviewState; private listeners = new Set(); @@ -439,6 +486,8 @@ export class PRReviewStore { replyingToCommentId: null, focusedPendingCommentId: null, editingPendingCommentId: null, + allCommentsCollapsed: getStoredCollapseAllComments(), + collapsedThreadOverrides: new Map(), semanticProviders: null, semanticStatus: "idle", semanticReview: null, @@ -750,6 +799,52 @@ export class PRReviewStore { } }; + // --------------------------------------------------------------------------- + // Comment Collapse Actions + // --------------------------------------------------------------------------- + + /** Collapse or expand every comment thread at once, dropping per-thread overrides. */ + setAllCommentsCollapsed = (collapsed: boolean) => { + setStoredCollapseAllComments(collapsed); + this.set({ + allCommentsCollapsed: collapsed, + collapsedThreadOverrides: new Map(), + }); + }; + + toggleAllCommentsCollapsed = () => { + this.setAllCommentsCollapsed(!this.state.allCommentsCollapsed); + }; + + /** Toggle a single thread, recording an override when it differs from the default. */ + setThreadCollapsed = ( + key: string, + collapsed: boolean, + isResolved = false + ) => { + const next = new Map(this.state.collapsedThreadOverrides); + const defaultCollapsed = this.state.allCommentsCollapsed || isResolved; + if (collapsed === defaultCollapsed) { + if (!next.has(key)) return; + next.delete(key); + } else { + if (next.get(key) === collapsed) return; + next.set(key, collapsed); + } + this.set({ collapsedThreadOverrides: next }); + }; + + toggleThreadCollapsed = (key: string, isResolved = false) => { + this.setThreadCollapsed( + key, + !isThreadCollapsed(this.state, key, isResolved), + isResolved + ); + }; + + isThreadCollapsed = (key: string, isResolved = false) => + isThreadCollapsed(this.state, key, isResolved); + // --------------------------------------------------------------------------- // Diff View Mode Actions // --------------------------------------------------------------------------- From 73ab6aa32b53eb456cb7f05b9861ce16cf6a1955 Mon Sep 17 00:00:00 2001 From: Taylor Kimmett Date: Wed, 26 Aug 2026 09:24:41 -0400 Subject: [PATCH 19/98] feat: make the sidebar width resizable by dragging its right edge Shared between the file tree and semantic sidebar, persisted to localStorage, clamped to 200-520px, double-click resets to default. Desktop only; the mobile drawer keeps the stored width capped at 85vw. Co-Authored-By: Claude Opus 5 --- src/browser/components/pr-review.tsx | 9 ++- src/browser/components/semantic-panel.tsx | 10 ++- src/browser/lib/sidebar-width.tsx | 96 +++++++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 src/browser/lib/sidebar-width.tsx diff --git a/src/browser/components/pr-review.tsx b/src/browser/components/pr-review.tsx index bd3d6f3..68211e7 100644 --- a/src/browser/components/pr-review.tsx +++ b/src/browser/components/pr-review.tsx @@ -50,6 +50,10 @@ import { PRHeader } from "./pr-header"; import { SemanticReviewButton } from "./semantic-review-button"; import { SemanticLayerBar, SemanticSidebar } from "./semantic-panel"; import { FileTree } from "./file-tree"; +import { + SidebarResizeHandle, + useSidebarWidth, +} from "@/browser/lib/sidebar-width"; import { isTestFile } from "@/browser/lib/test-file"; import { FileHeader } from "./file-header"; import type { PullRequest, PullRequestFile, ReviewComment } from "@/api/types"; @@ -508,6 +512,7 @@ const FilePanel = memo(function FilePanel({ [files, hideTestFiles, testFileCount] ); + const sidebarWidth = useSidebarWidth(); const commentCounts = useCommentCountsByFile(); const pendingCommentCounts = usePendingCommentCountsByFile(); const { copyDiff, copyFile, copyMainVersion } = useFileCopyActions(); @@ -529,11 +534,12 @@ const FilePanel = memo(function FilePanel({ return ( ); }); diff --git a/src/browser/components/semantic-panel.tsx b/src/browser/components/semantic-panel.tsx index 55f4b39..e6ff766 100644 --- a/src/browser/components/semantic-panel.tsx +++ b/src/browser/components/semantic-panel.tsx @@ -12,6 +12,10 @@ import { cn } from "../cn"; import { usePRReviewSelector, usePRReviewStore } from "../contexts/pr-review"; import { Markdown } from "../ui/markdown"; import { MermaidDiagram } from "./mermaid-diagram"; +import { + SidebarResizeHandle, + useSidebarWidth, +} from "@/browser/lib/sidebar-width"; import type { SemanticLayer, SemanticRange } from "@/semantic/schema"; // ============================================================================ @@ -50,6 +54,8 @@ export const SemanticSidebar = memo(function SemanticSidebar({ return n; }, [review, reviewedLayers]); + const sidebarWidth = useSidebarWidth(); + if (!review) return null; const stale = review.headSha !== pr.head.sha; @@ -57,10 +63,11 @@ export const SemanticSidebar = memo(function SemanticSidebar({ return ( ); }); diff --git a/src/browser/lib/sidebar-width.tsx b/src/browser/lib/sidebar-width.tsx new file mode 100644 index 0000000..2895532 --- /dev/null +++ b/src/browser/lib/sidebar-width.tsx @@ -0,0 +1,96 @@ +// ============================================================================ +// Resizable sidebar width +// ============================================================================ +// +// App-global preference persisted in localStorage, shared by the file-tree +// sidebar and the semantic-review sidebar so switching view modes doesn't +// change the layout. Resizing is desktop-only; the mobile drawer keeps the +// stored width but is capped by max-width in the sidebar's own classes. + +import { useCallback, useRef, useSyncExternalStore } from "react"; + +export const SIDEBAR_WIDTH_KEY = "pulldash_sidebar_width"; + +export const MIN_SIDEBAR_WIDTH = 200; +export const MAX_SIDEBAR_WIDTH = 520; +export const DEFAULT_SIDEBAR_WIDTH = 272; + +export function clampSidebarWidth(width: number): number { + if (!Number.isFinite(width)) return DEFAULT_SIDEBAR_WIDTH; + return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, width)); +} + +function getStoredWidth(): number { + try { + const stored = Number(localStorage.getItem(SIDEBAR_WIDTH_KEY)); + if (stored > 0) return clampSidebarWidth(stored); + } catch {} + return DEFAULT_SIDEBAR_WIDTH; +} + +let width = getStoredWidth(); +const listeners = new Set<() => void>(); + +function setSidebarWidth(next: number): void { + const clamped = clampSidebarWidth(next); + if (clamped === width) return; + width = clamped; + try { + localStorage.setItem(SIDEBAR_WIDTH_KEY, String(clamped)); + } catch {} + for (const l of listeners) l(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useSidebarWidth(): number { + return useSyncExternalStore( + subscribe, + () => width, + () => width + ); +} + +/** + * Invisible drag handle for the sidebar's right edge. Render as the last + * child of the sidebar `