From 71607385fa367b6242e7ffbe44c890625cda39a3 Mon Sep 17 00:00:00 2001 From: NePreby Date: Fri, 6 Nov 2026 12:10:05 +0700 Subject: [PATCH 1/5] feat(manifest): parse Cargo.lock and package-lock.json for transitive dependencies (closes #6) --- frontend/src/lib/__tests__/manifest.test.ts | 250 +++++++++++++++++- frontend/src/lib/manifest.ts | 272 +++++++++++++++++++- 2 files changed, 513 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/__tests__/manifest.test.ts b/frontend/src/lib/__tests__/manifest.test.ts index bc26051..aa7827a 100644 --- a/frontend/src/lib/__tests__/manifest.test.ts +++ b/frontend/src/lib/__tests__/manifest.test.ts @@ -1,9 +1,17 @@ import { describe, expect, it } from "vitest"; -import { equalWeights, parseManifest } from "../manifest"; +import { + equalWeights, + MAX_SPLIT_ROWS, + parseCargoLock, + parseCargoToml, + parseManifest, + parsePackageJson, + parsePackageLockJson, +} from "../manifest"; describe("parseCargoToml", () => { it("reads all common dependency forms", () => { - const m = parseManifest(` + const raw = ` [package] name = "demo" @@ -21,7 +29,8 @@ proptest = "1" [target.'cfg(unix)'.dependencies] libc = "0.2" # comment -`); +`; + const m = parseCargoToml(raw); expect(m.name).toBe("demo"); const byName = Object.fromEntries(m.deps.map((d) => [d.name, d])); expect(byName.serde.version).toBe("1.0"); @@ -36,17 +45,246 @@ libc = "0.2" # comment describe("parsePackageJson", () => { it("reads deps, peer and dev deps without duplicates", () => { - const m = parseManifest( - JSON.stringify({ name: "x", dependencies: { a: "^1" }, peerDependencies: { b: "2" }, devDependencies: { a: "1", c: "3" } }), - ); + const raw = JSON.stringify({ + name: "x", + dependencies: { a: "^1" }, + peerDependencies: { b: "2" }, + devDependencies: { a: "1", c: "3" }, + }); + const m = parsePackageJson(raw); expect(m.ecosystem).toBe("npm"); expect(m.deps.map((d) => `${d.name}:${d.kind}`)).toEqual(["a:normal", "b:peer", "c:dev"]); }); + it("errors on invalid json", () => { expect(() => parseManifest("{ nope", "package.json")).toThrow(/valid JSON/); }); }); +describe("parseCargoLock", () => { + const sampleCargoLock = ` +version = 3 + +[[package]] +name = "my-crate" +version = "0.1.0" +dependencies = [ + "serde", + "soroban-sdk 22.0.1", +] + +[[package]] +name = "serde" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "soroban-sdk" +version = "22.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "syn" +version = "2.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +`; + + it("parses direct dependencies only by default", () => { + const m = parseCargoLock(sampleCargoLock, { includeTransitive: false }); + expect(m.ecosystem).toBe("cargo"); + expect(m.name).toBe("my-crate"); + expect(m.deps.map((d) => d.name)).toEqual(["serde", "soroban-sdk"]); + expect(m.deps.find((d) => d.name === "serde")?.version).toBe("1.0.197"); + }); + + it("parses transitive dependencies when requested", () => { + const m = parseCargoLock(sampleCargoLock, { includeTransitive: true }); + expect(m.ecosystem).toBe("cargo"); + const names = m.deps.map((d) => d.name); + expect(names).toContain("serde"); + expect(names).toContain("serde_derive"); + expect(names).toContain("proc-macro2"); + expect(names).toContain("quote"); + expect(names).toContain("syn"); + expect(names).not.toContain("my-crate"); + }); + + it("caps results at MAX_SPLIT_ROWS (32) and de-duplicates", () => { + let bigLock = ` +[[package]] +name = "root" +version = "0.1.0" +dependencies = [] +`; + for (let i = 0; i < 50; i++) { + bigLock += ` +[[package]] +name = "dep-${i}" +version = "1.0.${i}" +source = "registry+https://..." +`; + } + // Duplicate one + bigLock += ` +[[package]] +name = "dep-0" +version = "1.0.99" +source = "registry+https://..." +`; + + const m = parseCargoLock(bigLock, { includeTransitive: true }); + expect(m.deps.length).toBe(MAX_SPLIT_ROWS); + expect(m.deps.length).toBeLessThanOrEqual(32); + const seen = new Set(m.deps.map((d) => d.name)); + expect(seen.size).toBe(m.deps.length); + }); + + it("routes through parseManifest with filename detection", () => { + const m = parseManifest(sampleCargoLock, "Cargo.lock", { includeTransitive: true }); + expect(m.ecosystem).toBe("cargo"); + expect(m.deps.length).toBeGreaterThan(2); + }); +}); + +describe("parsePackageLockJson", () => { + const sampleV3Lock = JSON.stringify({ + name: "flowpulse-frontend", + version: "0.1.0", + lockfileVersion: 3, + packages: { + "": { + name: "flowpulse-frontend", + version: "0.1.0", + dependencies: { + "@stellar/stellar-sdk": "^17.4.0", + react: "^19.3.1", + }, + devDependencies: { + typescript: "~5.9.3", + }, + }, + "node_modules/@stellar/stellar-sdk": { + version: "17.4.0", + dependencies: { + axios: "^1.7.0", + eventsource: "^2.0.2", + }, + }, + "node_modules/axios": { + version: "1.7.9", + }, + "node_modules/eventsource": { + version: "2.0.2", + }, + "node_modules/react": { + version: "19.3.1", + }, + "node_modules/typescript": { + version: "5.9.3", + dev: true, + }, + }, + }); + + it("extracts direct dependencies from v3 lockfile when includeTransitive is false", () => { + const m = parsePackageLockJson(sampleV3Lock, { includeTransitive: false }); + expect(m.ecosystem).toBe("npm"); + expect(m.name).toBe("flowpulse-frontend"); + const names = m.deps.map((d) => d.name); + expect(names).toEqual(["@stellar/stellar-sdk", "react", "typescript"]); + const ts = m.deps.find((d) => d.name === "typescript"); + expect(ts?.kind).toBe("dev"); + }); + + it("extracts transitive dependencies from v3 lockfile when includeTransitive is true", () => { + const m = parsePackageLockJson(sampleV3Lock, { includeTransitive: true }); + expect(m.ecosystem).toBe("npm"); + const names = m.deps.map((d) => d.name); + expect(names).toContain("@stellar/stellar-sdk"); + expect(names).toContain("axios"); + expect(names).toContain("eventsource"); + expect(names).toContain("react"); + expect(names).toContain("typescript"); + expect(names).not.toContain("flowpulse-frontend"); + }); + + it("extracts transitive dependencies from v1 lockfile", () => { + const sampleV1Lock = JSON.stringify({ + name: "v1-app", + version: "1.0.0", + lockfileVersion: 1, + dependencies: { + express: { + version: "4.19.2", + dependencies: { + accepts: { + version: "1.3.8", + dependencies: { + "mime-types": { + version: "2.1.35", + }, + }, + }, + }, + }, + }, + }); + + const direct = parsePackageLockJson(sampleV1Lock, { includeTransitive: false }); + expect(direct.deps.map((d) => d.name)).toEqual(["express"]); + + const transitive = parsePackageLockJson(sampleV1Lock, { includeTransitive: true }); + expect(transitive.deps.map((d) => d.name)).toEqual(["express", "accepts", "mime-types"]); + }); + + it("caps at maxRows and handles nested node_modules paths correctly", () => { + const sampleNested = JSON.stringify({ + name: "nested-app", + lockfileVersion: 2, + packages: { + "": { name: "nested-app" }, + "node_modules/a": { version: "1.0.0" }, + "node_modules/a/node_modules/b": { version: "2.0.0" }, + "node_modules/@scope/pkg": { version: "3.0.0" }, + }, + }); + + const m = parsePackageLockJson(sampleNested, { includeTransitive: true, maxRows: 2 }); + expect(m.deps.length).toBe(2); + expect(m.deps[0].name).toBe("a"); + expect(m.deps[1].name).toBe("b"); + }); + + it("routes through parseManifest with package-lock.json", () => { + const m = parseManifest(sampleV3Lock, "package-lock.json", { includeTransitive: true }); + expect(m.ecosystem).toBe("npm"); + expect(m.deps.map((d) => d.name)).toContain("axios"); + }); +}); + describe("equalWeights", () => { it("sums exactly to the target", () => { const w = equalWeights(7, 9000); diff --git a/frontend/src/lib/manifest.ts b/frontend/src/lib/manifest.ts index 306d01f..b4e4d68 100644 --- a/frontend/src/lib/manifest.ts +++ b/frontend/src/lib/manifest.ts @@ -1,8 +1,10 @@ /** * Dependency manifest parsing for split tables. - * Supports Cargo.toml, package.json, and Cargo.lock / package-lock.json (top-level only). + * Supports Cargo.toml, package.json, Cargo.lock, and package-lock.json (with transitive dependencies option). */ +export const MAX_SPLIT_ROWS = 32; + export type Ecosystem = "cargo" | "npm"; export interface Dependency { @@ -18,9 +20,34 @@ export interface Manifest { deps: Dependency[]; } -export function parseManifest(text: string, filename = ""): Manifest { +export interface ParseManifestOptions { + includeTransitive?: boolean; + maxRows?: number; +} + +export function parseManifest(text: string, filename = "", options?: ParseManifestOptions): Manifest { const t = text.trim(); - if (filename.endsWith(".json") || t.startsWith("{")) return parsePackageJson(t); + const lowerFile = filename.toLowerCase(); + + if (lowerFile.endsWith("cargo.lock") || (lowerFile.endsWith(".lock") && t.includes("[[package]]"))) { + return parseCargoLock(t, options); + } + + if ( + lowerFile.endsWith("package-lock.json") || + (lowerFile.endsWith(".json") && t.includes('"lockfileVersion"')) + ) { + return parsePackageLockJson(t, options); + } + + if (lowerFile.endsWith(".json") || t.startsWith("{")) { + return parsePackageJson(t); + } + + if (t.includes("[[package]]") && !t.includes("[package]") && !t.includes("[workspace]")) { + return parseCargoLock(t, options); + } + return parseCargoToml(t); } @@ -50,6 +77,245 @@ export function parsePackageJson(text: string): Manifest { return { ecosystem: "npm", name: typeof j.name === "string" ? j.name : undefined, deps }; } +/** + * Parses Cargo.lock and optionally includes transitive dependencies. + * De-duplicates and caps at maxRows (default MAX_SPLIT_ROWS = 32). + */ +export function parseCargoLock(text: string, options?: ParseManifestOptions): Manifest { + const includeTransitive = options?.includeTransitive ?? false; + const maxRows = options?.maxRows ?? MAX_SPLIT_ROWS; + + interface RawPackage { + name: string; + version: string; + source?: string; + dependencies: string[]; + } + + const packages: RawPackage[] = []; + let current: Partial | null = null; + let inDependencies = false; + + for (const raw of text.split(/\r?\n/)) { + const line = raw.replace(/\s+#.*$/, "").trim(); + if (!line || line.startsWith("#")) continue; + + if (line === "[[package]]") { + if (current && current.name && current.version) { + packages.push({ + name: current.name, + version: current.version, + source: current.source, + dependencies: current.dependencies ?? [], + }); + } + current = { dependencies: [] }; + inDependencies = false; + continue; + } + + if (!current) continue; + + if (inDependencies) { + if (line === "]") { + inDependencies = false; + continue; + } + const depMatch = line.match(/^"([^"]+)"/); + if (depMatch) { + const depRaw = depMatch[1].trim(); + const depName = depRaw.split(" ")[0]; + current.dependencies!.push(depName); + } + continue; + } + + const kv = line.match(/^("?)([A-Za-z0-9_\-]+)\1\s*=\s*(.+)$/); + if (!kv) continue; + const key = kv[2]; + const val = kv[3].trim(); + + if (key === "name") { + current.name = unquote(val); + } else if (key === "version") { + current.version = unquote(val); + } else if (key === "source") { + current.source = unquote(val); + } else if (key === "dependencies") { + if (val === "[") { + inDependencies = true; + } else if (val.startsWith("[") && val.endsWith("]")) { + const inner = val.slice(1, -1).trim(); + if (inner) { + const list = inner.split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean); + current.dependencies = list.map((d) => d.split(" ")[0]); + } + } + } + } + + if (current && current.name && current.version) { + packages.push({ + name: current.name, + version: current.version, + source: current.source, + dependencies: current.dependencies ?? [], + }); + } + + if (packages.length === 0) { + return { ecosystem: "cargo", deps: [] }; + } + + // Root packages are local packages without external source specification + const rootPackages = packages.filter((p) => !p.source); + const primaryRoot = rootPackages[0] ?? packages[0]; + const rootNames = new Set(rootPackages.map((p) => p.name)); + + const deps: Dependency[] = []; + const seen = new Set(); + + const pushDep = (name: string, version: string) => { + if (seen.has(name) || rootNames.has(name)) return; + seen.add(name); + deps.push({ + name, + version, + ecosystem: "cargo", + kind: "normal", + }); + }; + + if (!includeTransitive) { + // Only direct dependencies of the root packages + const directDeps = new Set(); + for (const r of rootPackages) { + for (const d of r.dependencies) { + directDeps.add(d); + } + } + for (const pkg of packages) { + if (directDeps.has(pkg.name)) { + pushDep(pkg.name, pkg.version); + if (deps.length >= maxRows) break; + } + } + } else { + // All unique transitive dependencies across the entire lock graph + for (const pkg of packages) { + pushDep(pkg.name, pkg.version); + if (deps.length >= maxRows) break; + } + } + + return { + ecosystem: "cargo", + name: primaryRoot?.name, + deps: deps.slice(0, maxRows), + }; +} + +/** + * Parses package-lock.json (v1, v2, v3 formats) and optionally includes transitive dependencies. + * De-duplicates and caps at maxRows (default MAX_SPLIT_ROWS = 32). + */ +export function parsePackageLockJson(text: string, options?: ParseManifestOptions): Manifest { + const includeTransitive = options?.includeTransitive ?? false; + const maxRows = options?.maxRows ?? MAX_SPLIT_ROWS; + + let j: Record; + try { + j = JSON.parse(text); + } catch { + throw new Error("package-lock.json is not valid JSON"); + } + + const rootName = typeof j.name === "string" ? j.name : undefined; + const deps: Dependency[] = []; + const seen = new Set(); + + const pushDep = (name: string, version: string, kind: Dependency["kind"] = "normal") => { + if (!name || seen.has(name) || name === rootName) return; + seen.add(name); + deps.push({ + name, + version: String(version || "*"), + ecosystem: "npm", + kind, + }); + }; + + // Modern npm lockfile format: v2 / v3 using "packages" + if (j.packages && typeof j.packages === "object") { + const pkgs = j.packages as Record>; + const rootPkg = pkgs[""] || {}; + const directDeps = (rootPkg.dependencies || {}) as Record; + const directDevDeps = (rootPkg.devDependencies || {}) as Record; + const directPeerDeps = (rootPkg.peerDependencies || {}) as Record; + + if (!includeTransitive) { + for (const [name, reqVer] of Object.entries(directDeps)) { + const resolved = pkgs[`node_modules/${name}`]?.version; + pushDep(name, String(resolved || reqVer), "normal"); + if (deps.length >= maxRows) break; + } + for (const [name, reqVer] of Object.entries(directPeerDeps)) { + if (deps.length >= maxRows) break; + const resolved = pkgs[`node_modules/${name}`]?.version; + pushDep(name, String(resolved || reqVer), "peer"); + } + for (const [name, reqVer] of Object.entries(directDevDeps)) { + if (deps.length >= maxRows) break; + const resolved = pkgs[`node_modules/${name}`]?.version; + pushDep(name, String(resolved || reqVer), "dev"); + } + } else { + // Collect all packages from node_modules/* + for (const [pathKey, val] of Object.entries(pkgs)) { + if (!pathKey || pathKey === "" || !val || typeof val !== "object") continue; + const lastIndex = pathKey.lastIndexOf("node_modules/"); + if (lastIndex === -1) continue; + const rawPkgName = pathKey.slice(lastIndex + "node_modules/".length); + if (!rawPkgName) continue; + + const isDev = Boolean(val.dev); + const isPeer = Boolean(val.peer); + const kind: Dependency["kind"] = isPeer ? "peer" : isDev ? "dev" : "normal"; + const ver = typeof val.version === "string" ? val.version : "*"; + + pushDep(rawPkgName, ver, kind); + if (deps.length >= maxRows) break; + } + } + } else if (j.dependencies && typeof j.dependencies === "object") { + // Legacy npm lockfile format: v1 + const depsTree = j.dependencies as Record>; + + const walkV1 = (tree: Record>) => { + for (const [name, node] of Object.entries(tree)) { + if (!node || typeof node !== "object") continue; + const ver = typeof node.version === "string" ? node.version : "*"; + const isDev = Boolean(node.dev); + pushDep(name, ver, isDev ? "dev" : "normal"); + if (deps.length >= maxRows) return; + + if (includeTransitive && node.dependencies && typeof node.dependencies === "object") { + walkV1(node.dependencies as Record>); + if (deps.length >= maxRows) return; + } + } + }; + + walkV1(depsTree); + } + + return { + ecosystem: "npm", + name: rootName, + deps: deps.slice(0, maxRows), + }; +} + /** * Minimal TOML reader covering the dependency tables used in practice: * [dependencies] foo = "1" | foo = { version = "1", ... } | foo.workspace = true From 1f8907d0432cba94925733fc943e9ea803dfbae1 Mon Sep 17 00:00:00 2001 From: NePreby Date: Fri, 6 Nov 2026 13:14:59 +0700 Subject: [PATCH 2/5] feat(ui): trap focus in sheets and restore focus on close (closes #14) --- frontend/src/components/ui.tsx | 53 ++++++++++- frontend/src/lib/__tests__/focus.test.ts | 116 +++++++++++++++++++++++ frontend/src/lib/focus.ts | 53 +++++++++++ 3 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/__tests__/focus.test.ts create mode 100644 frontend/src/lib/focus.ts diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx index 67d182a..0dbeee4 100644 --- a/frontend/src/components/ui.tsx +++ b/frontend/src/components/ui.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useState, useRef, type ReactNode } from "react"; +import { handleFocusTrap, FOCUSABLE_SELECTOR } from "../lib/focus"; export function Icon({ name, className = "", fill = false }: { name: string; className?: string; fill?: boolean }) { return ( @@ -60,20 +61,66 @@ export function Sheet({ children: ReactNode; wide?: boolean; }) { + const triggerRef = useRef(null); + const dialogRef = useRef(null); + useEffect(() => { if (!open) return; - const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + + // Capture the trigger element before opening + triggerRef.current = document.activeElement as HTMLElement | null; + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + return; + } + + if (dialogRef.current) { + handleFocusTrap({ + container: dialogRef.current, + event: e, + activeElement: document.activeElement, + }); + } + }; + window.addEventListener("keydown", onKey); const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; + + // Focus into dialog on open + const timer = setTimeout(() => { + if (dialogRef.current) { + const focusables = dialogRef.current.querySelectorAll(FOCUSABLE_SELECTOR); + if (focusables.length > 0) { + focusables[0].focus(); + } else { + dialogRef.current.focus(); + } + } + }, 0); + return () => { + clearTimeout(timer); window.removeEventListener("keydown", onKey); document.body.style.overflow = prev; + // Restore focus to trigger element on close + triggerRef.current?.focus(); }; }, [open, onClose]); + if (!open) return null; + return ( -
+