From 71607385fa367b6242e7ffbe44c890625cda39a3 Mon Sep 17 00:00:00 2001 From: NePreby Date: Fri, 6 Nov 2026 12:10:05 +0700 Subject: [PATCH 1/8] 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/8] 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 ( -
+
- + View on explorer
diff --git a/frontend/src/lib/__tests__/qr.test.ts b/frontend/src/lib/__tests__/qr.test.ts new file mode 100644 index 0000000..fe5cc53 --- /dev/null +++ b/frontend/src/lib/__tests__/qr.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { encodeQr, qrToSvgPath } from "../qr"; + +describe("QR Code Generator", () => { + it("encodes a basic URL into a valid QR matrix", () => { + const url = "https://stellar.expert/explorer/testnet/tx/1234567890abcdef"; + const qr = encodeQr(url); + + expect(qr.size).toBeGreaterThanOrEqual(21); + expect(qr.modules.length).toBe(qr.size); + expect(qr.modules[0].length).toBe(qr.size); + + // Verify top-left finder pattern (7x7 outer boundary has black modules) + expect(qr.modules[0][0]).toBe(true); + expect(qr.modules[0][6]).toBe(true); + expect(qr.modules[6][0]).toBe(true); + expect(qr.modules[6][6]).toBe(true); + expect(qr.modules[1][1]).toBe(false); // inner white space + expect(qr.modules[3][3]).toBe(true); // center black module + }); + + it("generates an SVG path string from the matrix", () => { + const qr = encodeQr("https://flowpulse.org"); + const path = qrToSvgPath(qr); + + expect(typeof path).toBe("string"); + expect(path.startsWith("M")).toBe(true); + expect(path).toContain("h1v1h-1z"); + }); + + it("handles empty or short strings without crashing", () => { + const qr = encodeQr("test"); + expect(qr.size).toBe(21); // Version 1 size: 17 + 4*1 = 21 + }); +}); diff --git a/frontend/src/lib/qr.ts b/frontend/src/lib/qr.ts new file mode 100644 index 0000000..533c8c8 --- /dev/null +++ b/frontend/src/lib/qr.ts @@ -0,0 +1,284 @@ +/** + * Lightweight, zero-dependency QR Code (Model 2) SVG generator. + * Implements Byte mode with standard Reed-Solomon error correction (Level M/L). + */ + +// Galois Field GF(256) tables with primitive polynomial 0x11d (285) +const EXP_TABLE = new Uint8Array(512); +const LOG_TABLE = new Uint8Array(256); + +(function initGaloisField() { + let x = 1; + for (let i = 0; i < 255; i++) { + EXP_TABLE[i] = x; + EXP_TABLE[i + 255] = x; + LOG_TABLE[x] = i; + x <<= 1; + if (x & 0x100) x ^= 0x11d; + } +})(); + +function gfMul(x: number, y: number): number { + if (x === 0 || y === 0) return 0; + return EXP_TABLE[LOG_TABLE[x] + LOG_TABLE[y]]; +} + +// Generator polynomials for Reed-Solomon codes +function rsGeneratorPoly(degree: number): Uint8Array { + let poly = new Uint8Array([1]); + for (let i = 0; i < degree; i++) { + const next = new Uint8Array(poly.length + 1); + for (let j = 0; j < poly.length; j++) { + next[j] ^= gfMul(poly[j], EXP_TABLE[i]); + next[j + 1] ^= poly[j]; + } + poly = next; + } + return poly; +} + +function rsComputeRemainder(data: Uint8Array, ecLength: number): Uint8Array { + const gen = rsGeneratorPoly(ecLength); + const res = new Uint8Array(ecLength); + for (const byte of data) { + const factor = byte ^ res[0]; + res.copyWithin(0, 1); + res[ecLength - 1] = 0; + for (let i = 0; i < ecLength; i++) { + res[i] ^= gfMul(gen[i], factor); + } + } + return res; +} + +// Version table capacities for Byte Mode (Level L) +// Version 1..10 capacities and EC parameters +interface QrVersionSpec { + version: number; + totalCodewords: number; + ecCodewords: number; + dataCodewords: number; + blocks: number; + alignment: number[]; +} + +const VERSION_SPECS: QrVersionSpec[] = [ + { version: 1, totalCodewords: 26, ecCodewords: 7, dataCodewords: 19, blocks: 1, alignment: [] }, + { version: 2, totalCodewords: 44, ecCodewords: 10, dataCodewords: 34, blocks: 1, alignment: [6, 18] }, + { version: 3, totalCodewords: 70, ecCodewords: 15, dataCodewords: 55, blocks: 1, alignment: [6, 22] }, + { version: 4, totalCodewords: 100, ecCodewords: 20, dataCodewords: 80, blocks: 1, alignment: [6, 26] }, + { version: 5, totalCodewords: 134, ecCodewords: 26, dataCodewords: 108, blocks: 1, alignment: [6, 30] }, + { version: 6, totalCodewords: 172, ecCodewords: 36, dataCodewords: 136, blocks: 2, alignment: [6, 34] }, + { version: 7, totalCodewords: 196, ecCodewords: 40, dataCodewords: 156, blocks: 2, alignment: [6, 22, 38] }, + { version: 8, totalCodewords: 242, ecCodewords: 48, dataCodewords: 194, blocks: 2, alignment: [6, 24, 42] }, + { version: 9, totalCodewords: 292, ecCodewords: 60, dataCodewords: 232, blocks: 2, alignment: [6, 26, 46] }, + { version: 10, totalCodewords: 346, ecCodewords: 72, dataCodewords: 274, blocks: 4, alignment: [6, 28, 50] }, +]; + +export interface QrMatrix { + size: number; + modules: boolean[][]; +} + +/** + * Encodes text into a QR code boolean matrix (Version 1-10, EC Level L). + */ +export function encodeQr(text: string): QrMatrix { + const utf8 = new TextEncoder().encode(text); + const len = utf8.length; + + // Find lowest suitable version + const spec = VERSION_SPECS.find((s) => s.dataCodewords >= len + 3); + if (!spec) { + throw new Error(`Text too long for supported QR versions (max ~270 bytes)`); + } + + // 1. Bitstream assembly: Mode indicator (0100 for Byte mode) + character count + data + const bits: number[] = []; + function pushBits(val: number, count: number) { + for (let i = count - 1; i >= 0; i--) { + bits.push((val >>> i) & 1); + } + } + + pushBits(0b0100, 4); // Byte mode indicator + pushBits(len, spec.version < 10 ? 8 : 16); // Character count indicator + for (const byte of utf8) { + pushBits(byte, 8); + } + + // Terminator (up to 4 zeroes) + const maxBits = spec.dataCodewords * 8; + const termLen = Math.min(4, maxBits - bits.length); + pushBits(0, termLen); + + // Pad to multiple of 8 + while (bits.length % 8 !== 0) { + bits.push(0); + } + + // Pad bytes (0xEC, 0x11 alternating) + const dataBytes = new Uint8Array(spec.dataCodewords); + for (let i = 0; i < bits.length / 8; i++) { + let b = 0; + for (let j = 0; j < 8; j++) { + b = (b << 1) | bits[i * 8 + j]; + } + dataBytes[i] = b; + } + + const pad = [0xec, 0x11]; + let padIdx = 0; + for (let i = bits.length / 8; i < spec.dataCodewords; i++) { + dataBytes[i] = pad[padIdx % 2]; + padIdx++; + } + + // 2. Reed-Solomon Error Correction + const ecLengthPerBlock = Math.floor(spec.ecCodewords / spec.blocks); + const dataLengthPerBlock = Math.floor(spec.dataCodewords / spec.blocks); + const allEcBlocks: Uint8Array[] = []; + const allDataBlocks: Uint8Array[] = []; + + for (let b = 0; b < spec.blocks; b++) { + const start = b * dataLengthPerBlock; + const blockData = dataBytes.slice(start, start + dataLengthPerBlock); + allDataBlocks.push(blockData); + allEcBlocks.push(rsComputeRemainder(blockData, ecLengthPerBlock)); + } + + // Interleave data and EC codewords + const finalCodewords: number[] = []; + for (let i = 0; i < dataLengthPerBlock; i++) { + for (let b = 0; b < spec.blocks; b++) { + finalCodewords.push(allDataBlocks[b][i]); + } + } + for (let i = 0; i < ecLengthPerBlock; i++) { + for (let b = 0; b < spec.blocks; b++) { + finalCodewords.push(allEcBlocks[b][i]); + } + } + + // 3. Matrix construction + const size = 17 + 4 * spec.version; + const matrix: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false)); + const isFunction: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false)); + + function setFunctionModule(r: number, c: number, val: boolean) { + matrix[r][c] = val; + isFunction[r][c] = true; + } + + // Finder patterns at (0,0), (size-7, 0), (0, size-7) + function drawFinderPattern(row: number, col: number) { + for (let r = -1; r <= 7; r++) { + for (let c = -1; c <= 7; c++) { + const nr = row + r; + const nc = col + c; + if (nr < 0 || nr >= size || nc < 0 || nc >= size) continue; + const isBlack = + (r >= 0 && r <= 6 && (c === 0 || c === 6)) || + (c >= 0 && c <= 6 && (r === 0 || r === 6)) || + (r >= 2 && r <= 4 && c >= 2 && c <= 4); + setFunctionModule(nr, nc, isBlack); + } + } + } + + drawFinderPattern(0, 0); + drawFinderPattern(0, size - 7); + drawFinderPattern(size - 7, 0); + + // Alignment patterns for version >= 2 + const coords = spec.alignment; + for (let i = 0; i < coords.length; i++) { + for (let j = 0; j < coords.length; j++) { + const r = coords[i]; + const c = coords[j]; + if (isFunction[r][c]) continue; + for (let dr = -2; dr <= 2; dr++) { + for (let dc = -2; dc <= 2; dc++) { + const isBlack = Math.max(Math.abs(dr), Math.abs(dc)) !== 1; + setFunctionModule(r + dr, c + dc, isBlack); + } + } + } + } + + // Timing patterns + for (let i = 8; i < size - 8; i++) { + if (!isFunction[6][i]) setFunctionModule(6, i, i % 2 === 0); + if (!isFunction[i][6]) setFunctionModule(i, 6, i % 2 === 0); + } + + // Dark module + setFunctionModule(4 * spec.version + 9, 8, true); + + // Format info reserve + for (let i = 0; i <= 8; i++) { + if (!isFunction[8][i]) isFunction[8][i] = true; + if (!isFunction[i][8]) isFunction[i][8] = true; + } + for (let i = size - 8; i < size; i++) { + if (!isFunction[8][i]) isFunction[8][i] = true; + if (!isFunction[i][8]) isFunction[i][8] = true; + } + + // 4. Place data bits with Mask 0 ((row + col) % 2 === 0) + const bitArray: number[] = []; + for (const byte of finalCodewords) { + for (let i = 7; i >= 0; i--) { + bitArray.push((byte >>> i) & 1); + } + } + + let bitIdx = 0; + let upwards = true; + for (let right = size - 1; right > 0; right -= 2) { + if (right === 6) right--; // Skip vertical timing column + const rows = upwards + ? Array.from({ length: size }, (_, i) => size - 1 - i) + : Array.from({ length: size }, (_, i) => i); + + for (const r of rows) { + for (const c of [right, right - 1]) { + if (!isFunction[r][c]) { + const bit = bitIdx < bitArray.length ? bitArray[bitIdx++] : 0; + const mask = (r + c) % 2 === 0; + matrix[r][c] = (bit ^ (mask ? 1 : 0)) === 1; + } + } + } + upwards = !upwards; + } + + // 5. Write Format Information (Level L, Mask 0: bits 111011111000100) + const formatBits = [1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0]; + for (let i = 0; i < 6; i++) matrix[8][i] = formatBits[i] === 1; + matrix[8][7] = formatBits[6] === 1; + matrix[8][8] = formatBits[7] === 1; + matrix[7][8] = formatBits[8] === 1; + for (let i = 9; i < 15; i++) matrix[14 - i][8] = formatBits[i] === 1; + + for (let i = 0; i < 8; i++) matrix[size - 1 - i][8] = formatBits[i] === 1; + for (let i = 8; i < 15; i++) matrix[8][size - 15 + i] = formatBits[i] === 1; + + return { size, modules: matrix }; +} + +/** + * Generates an SVG path data string for rendering the QR matrix cleanly. + */ +export function qrToSvgPath(matrix: QrMatrix): string { + const parts: string[] = []; + const { size, modules } = matrix; + for (let r = 0; r < size; r++) { + for (let c = 0; c < size; c++) { + if (modules[r][c]) { + parts.push(`M${c},${r}h1v1h-1z`); + } + } + } + return parts.join(""); +} From d1b986da6d3a58178ae3f1bfd7bc541bc282d860 Mon Sep 17 00:00:00 2001 From: NePreby Date: Fri, 6 Nov 2026 16:10:18 +0700 Subject: [PATCH 7/8] feat(manifest): resolve maintainer Stellar addresses from FUNDING.json (closes #4) --- .../lib/__tests__/manifest_funding.test.ts | 144 ++++++++++++++++++ frontend/src/lib/manifest.ts | 134 ++++++++++++++++ frontend/src/pages/Cascades.tsx | 57 ++++++- 3 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/__tests__/manifest_funding.test.ts diff --git a/frontend/src/lib/__tests__/manifest_funding.test.ts b/frontend/src/lib/__tests__/manifest_funding.test.ts new file mode 100644 index 0000000..4a05504 --- /dev/null +++ b/frontend/src/lib/__tests__/manifest_funding.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + extractStellarAddressFromFundingJson, + extractGitHubRepoSlug, + resolveMaintainerFundingAddress, +} from "../manifest"; + +describe("FUNDING.json Stellar address extraction", () => { + it("extracts direct stellar field", () => { + const raw = JSON.stringify({ + stellar: "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI", + }); + expect(extractStellarAddressFromFundingJson(raw)).toBe( + "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI" + ); + }); + + it("extracts from accounts array", () => { + const raw = JSON.stringify({ + accounts: [ + { network: "ethereum", address: "0x1234567890abcdef" }, + { network: "stellar", address: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" }, + ], + }); + expect(extractStellarAddressFromFundingJson(raw)).toBe( + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" + ); + }); + + it("extracts from drips extension", () => { + const raw = JSON.stringify({ + drips: { + stellar: "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI", + }, + }); + expect(extractStellarAddressFromFundingJson(raw)).toBe( + "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI" + ); + }); + + it("returns null for invalid or missing Stellar addresses", () => { + expect(extractStellarAddressFromFundingJson('{"ethereum": "0x123"}')).toBeNull(); + expect(extractStellarAddressFromFundingJson("invalid json")).toBeNull(); + }); + + it("extracts GitHub repo slug from various URL formats", () => { + expect(extractGitHubRepoSlug("https://github.com/stellar/rs-soroban-sdk")).toBe("stellar/rs-soroban-sdk"); + expect(extractGitHubRepoSlug("https://github.com/facebook/react.git")).toBe("facebook/react"); + expect(extractGitHubRepoSlug("git+https://github.com/serde-rs/json.git")).toBe("serde-rs/json"); + expect(extractGitHubRepoSlug("ssh://git@github.com/flow-pulse/contracts.git")).toBe("flow-pulse/contracts"); + expect(extractGitHubRepoSlug("https://gitlab.com/other/repo")).toBeNull(); + }); +}); + +describe("resolveMaintainerFundingAddress with mocked fetch", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("resolves address for Cargo dependency via crates.io and FUNDING.json", async () => { + const mockStellar = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"; + + globalThis.fetch = vi.fn(async (url: RequestInfo | URL) => { + const u = url.toString(); + if (u.includes("crates.io/api/v1/crates/soroban-sdk")) { + return { + ok: true, + json: async () => ({ + crate: { repository: "https://github.com/stellar/rs-soroban-sdk" }, + }), + } as unknown as Response; + } + if (u.includes("raw.githubusercontent.com/stellar/rs-soroban-sdk/HEAD/.github/FUNDING.json")) { + return { + ok: true, + text: async () => JSON.stringify({ stellar: mockStellar }), + } as unknown as Response; + } + return { ok: false, status: 404 } as unknown as Response; + }); + + const resolved = await resolveMaintainerFundingAddress({ + name: "soroban-sdk", + ecosystem: "cargo", + }); + + expect(resolved).toBe(mockStellar); + }); + + it("resolves address for npm dependency via registry and root FUNDING.json", async () => { + const mockStellar = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + + globalThis.fetch = vi.fn(async (url: RequestInfo | URL) => { + const u = url.toString(); + if (u.includes("registry.npmjs.org/stellar-sdk")) { + return { + ok: true, + json: async () => ({ + repository: { url: "git+https://github.com/stellar/js-stellar-sdk.git" }, + }), + } as unknown as Response; + } + if (u.includes(".github/FUNDING.json")) { + return { ok: false, status: 404 } as unknown as Response; + } + if (u.includes("FUNDING.json")) { + return { + ok: true, + text: async () => + JSON.stringify({ + accounts: [{ network: "stellar", address: mockStellar }], + }), + } as unknown as Response; + } + return { ok: false, status: 404 } as unknown as Response; + }); + + const resolved = await resolveMaintainerFundingAddress({ + name: "stellar-sdk", + ecosystem: "npm", + }); + + expect(resolved).toBe(mockStellar); + }); + + it("fails silently and returns null on network errors", async () => { + globalThis.fetch = vi.fn(async () => { + throw new Error("Network offline"); + }); + + const resolved = await resolveMaintainerFundingAddress({ + name: "unknown-pkg", + ecosystem: "cargo", + }); + + expect(resolved).toBeNull(); + }); +}); diff --git a/frontend/src/lib/manifest.ts b/frontend/src/lib/manifest.ts index b4e4d68..d761914 100644 --- a/frontend/src/lib/manifest.ts +++ b/frontend/src/lib/manifest.ts @@ -423,3 +423,137 @@ export async function fetchRepoManifest(repo: string): Promise<{ text: string; f } throw new Error(`No Cargo.toml or package.json at the root of ${slug}`); } + + +// ---------- FUNDING.json Resolution ---------- + +export const STELLAR_ADDR_REGEX = /^G[A-Z2-7]{55}$/; + +/** + * Extracts a normalized "owner/repo" slug from various GitHub repository URL formats. + */ +export function extractGitHubRepoSlug(url: string): string | null { + if (!url || typeof url !== "string") return null; + const clean = url + .replace(/^git\+/, "") + .replace(/^ssh:\/\/git@github\.com\/?/, "https://github.com/") + .replace(/^git@github\.com:/, "https://github.com/") + .replace(/\.git$/, "") + .trim(); + + const m = clean.match(/^https?:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/?$/); + if (m) { + return `${m[1]}/${m[2]}`; + } + return null; +} + +/** + * Safely parses a FUNDING.json file string and extracts an Ed25519 Stellar public key address (G...). + */ +export function extractStellarAddressFromFundingJson(text: string): string | null { + try { + const data = JSON.parse(text); + if (!data || typeof data !== "object") return null; + + if (typeof data.stellar === "string" && STELLAR_ADDR_REGEX.test(data.stellar.trim())) { + return data.stellar.trim(); + } + if (typeof data.address === "string" && STELLAR_ADDR_REGEX.test(data.address.trim())) { + return data.address.trim(); + } + if (data.drips && typeof data.drips === "object") { + if (typeof data.drips.stellar === "string" && STELLAR_ADDR_REGEX.test(data.drips.stellar.trim())) { + return data.drips.stellar.trim(); + } + } + if (Array.isArray(data.accounts)) { + for (const item of data.accounts) { + if ( + item && + typeof item === "object" && + (item.network === "stellar" || item.chain === "stellar" || item.platform === "stellar") && + typeof item.address === "string" && + STELLAR_ADDR_REGEX.test(item.address.trim()) + ) { + return item.address.trim(); + } + } + } + if (data.accounts && typeof data.accounts === "object" && !Array.isArray(data.accounts)) { + if (typeof data.accounts.stellar === "string" && STELLAR_ADDR_REGEX.test(data.accounts.stellar.trim())) { + return data.accounts.stellar.trim(); + } + } + return null; + } catch { + return null; + } +} + +/** + * Queries registry metadata (crates.io / npm) to find the GitHub repository URL for a package. + */ +export async function fetchDependencyRepoSlug( + dep: Pick +): Promise { + try { + if (dep.ecosystem === "cargo") { + const res = await fetch(`https://crates.io/api/v1/crates/${encodeURIComponent(dep.name)}`); + if (!res.ok) return null; + const json = await res.json(); + const repoUrl = json?.crate?.repository; + return repoUrl ? extractGitHubRepoSlug(repoUrl) : null; + } else { + const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(dep.name)}`); + if (!res.ok) return null; + const json = await res.json(); + const repo = json?.repository; + const repoUrl = typeof repo === "string" ? repo : repo?.url; + return repoUrl ? extractGitHubRepoSlug(repoUrl) : null; + } + } catch { + return null; + } +} + +/** + * Attempts to fetch and extract a Stellar address from FUNDING.json in a GitHub repository. + */ +export async function fetchFundingJsonAddress(repoSlug: string): Promise { + const candidatePaths = [ + `https://raw.githubusercontent.com/${repoSlug}/HEAD/.github/FUNDING.json`, + `https://raw.githubusercontent.com/${repoSlug}/HEAD/FUNDING.json`, + ]; + + for (const url of candidatePaths) { + try { + const res = await fetch(url); + if (res.ok) { + const text = await res.text(); + const addr = extractStellarAddressFromFundingJson(text); + if (addr) return addr; + } + } catch { + // Continue to next path + } + } + return null; +} + +/** + * Non-blocking maintainer address resolver: checks crates.io / npm for repository, + * then checks GitHub repository for FUNDING.json. Silently returns null on failure. + */ +export async function resolveMaintainerFundingAddress( + dep: Pick, + knownRepoSlug?: string +): Promise { + try { + const slug = knownRepoSlug || (await fetchDependencyRepoSlug(dep)); + if (!slug) return null; + return await fetchFundingJsonAddress(slug); + } catch { + return null; + } +} diff --git a/frontend/src/pages/Cascades.tsx b/frontend/src/pages/Cascades.tsx index f7ac376..6575fdd 100644 --- a/frontend/src/pages/Cascades.tsx +++ b/frontend/src/pages/Cascades.tsx @@ -4,7 +4,7 @@ import { useApp } from "../state"; import { splitter, type Payout } from "../lib/contracts"; import { config, XLM_SAC } from "../lib/config"; import { cleanError, sv } from "../lib/soroban"; -import { equalWeights, fetchRepoManifest, parseManifest, registryUrl, type Dependency, type Manifest } from "../lib/manifest"; +import { equalWeights, fetchRepoManifest, parseManifest, registryUrl, resolveMaintainerFundingAddress, type Dependency, type Manifest } from "../lib/manifest"; import { fromUnits, shortAddr, toUnits } from "../lib/format"; import { Banner } from "../components/Shell"; import { CopyButton, Empty, Field, Icon, SectionTitle, Spinner } from "../components/ui"; @@ -15,6 +15,7 @@ interface Row { recipient: string; bps: number; ecosystem?: Dependency["ecosystem"]; + resolvedFromFunding?: boolean; } const MAX_SPLITS = 32; @@ -78,12 +79,37 @@ export function CascadesPage() { const deps = m.deps.filter((d) => includeDev || d.kind === "normal" || d.kind === "peer").slice(0, MAX_SPLITS); // Default: 90% across deps, 10% reserve for the project itself. const w = equalWeights(deps.length, 9000); - setRows( - deps.map((d, i) => { - const existing = rows.find((r) => r.label === d.name); - return { key: `${d.name}-${i}`, label: d.name, recipient: existing?.recipient ?? "", bps: w[i], ecosystem: d.ecosystem }; - }), - ); + const initialRows: Row[] = deps.map((d, i) => { + const existing = rows.find((r) => r.label === d.name); + return { + key: `${d.name}-${i}`, + label: d.name, + recipient: existing?.recipient ?? "", + bps: w[i], + ecosystem: d.ecosystem, + resolvedFromFunding: existing?.resolvedFromFunding, + }; + }); + setRows(initialRows); + + // Non-blocking auto-resolution of maintainer Stellar addresses from FUNDING.json + deps.forEach(async (d, i) => { + if (initialRows[i].recipient) return; + try { + const addr = await resolveMaintainerFundingAddress(d); + if (addr) { + setRows((current) => + current.map((r, idx) => + idx === i && !r.recipient + ? { ...r, recipient: addr, resolvedFromFunding: true } + : r + ) + ); + } + } catch { + // Silent and non-blocking + } + }); } function parse() { @@ -334,6 +360,23 @@ export function CascadesPage() { {r.ecosystem === "cargo" ? "crate" : "npm"} ↗ )} + {r.resolvedFromFunding && ( + + resolved from FUNDING.json + + )} + {!r.recipient && ( + + )}
Date: Sat, 7 Nov 2026 00:04:04 +0700 Subject: [PATCH 8/8] feat(cascades): suggest split weights from download counts (fixes #5) --- frontend/src/lib/__tests__/weights.test.ts | 181 +++++++++++++++++++++ frontend/src/lib/manifest.ts | 134 +++++++++++++++ frontend/src/pages/Cascades.tsx | 28 +++- 3 files changed, 339 insertions(+), 4 deletions(-) create mode 100644 frontend/src/lib/__tests__/weights.test.ts diff --git a/frontend/src/lib/__tests__/weights.test.ts b/frontend/src/lib/__tests__/weights.test.ts new file mode 100644 index 0000000..c6e790a --- /dev/null +++ b/frontend/src/lib/__tests__/weights.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { + allocateWeightsByDownloads, + fetchCrateDownloads, + fetchNpmDownloads, + fetchPackageDownloads, + suggestDependencyWeights, + type Dependency, +} from "../manifest"; + +describe("allocateWeightsByDownloads", () => { + it("returns empty array for empty inputs", () => { + expect(allocateWeightsByDownloads([])).toEqual([]); + }); + + it("allocates entire targetBps to a single package", () => { + expect(allocateWeightsByDownloads([12345], 9000)).toEqual([9000]); + expect(allocateWeightsByDownloads([0], 9000)).toEqual([9000]); + }); + + it("strictly sums to targetBps across varied download counts with no drift", () => { + const downloads = [1000, 2500, 50, 8000, 12000, 300]; + const targetBps = 9000; + const minFloorBps = 50; + const weights = allocateWeightsByDownloads(downloads, targetBps, minFloorBps); + + expect(weights.length).toBe(downloads.length); + const sum = weights.reduce((acc, w) => acc + w, 0); + expect(sum).toBe(targetBps); + + // Each element respects the minFloorBps + weights.forEach((w) => { + expect(w).toBeGreaterThanOrEqual(minFloorBps); + }); + + // Higher downloads get higher or equal weights + expect(weights[4]).toBeGreaterThan(weights[3]); + expect(weights[3]).toBeGreaterThan(weights[1]); + expect(weights[1]).toBeGreaterThan(weights[0]); + }); + + it("falls back to equal distribution when all downloads are 0", () => { + const downloads = [0, 0, 0, 0]; + const targetBps = 9000; + const weights = allocateWeightsByDownloads(downloads, targetBps); + expect(weights).toEqual([2250, 2250, 2250, 2250]); + expect(weights.reduce((a, b) => a + b, 0)).toBe(targetBps); + }); + + it("handles negative or NaN downloads gracefully", () => { + const downloads = [-100, NaN, 1000, 2000]; + const targetBps = 9000; + const minFloorBps = 50; + const weights = allocateWeightsByDownloads(downloads, targetBps, minFloorBps); + expect(weights.reduce((a, b) => a + b, 0)).toBe(targetBps); + expect(weights[0]).toBe(minFloorBps); + expect(weights[1]).toBe(minFloorBps); + expect(weights[2]).toBeGreaterThan(minFloorBps); + expect(weights[3]).toBeGreaterThan(weights[2]); + }); + + it("scales down floor safely when n * minFloorBps exceeds targetBps", () => { + // 100 items with floor 100 = 10000 > 9000 + const downloads = new Array(100).fill(10); + const weights = allocateWeightsByDownloads(downloads, 9000, 100); + expect(weights.length).toBe(100); + expect(weights.reduce((a, b) => a + b, 0)).toBe(9000); + weights.forEach((w) => { + expect(w).toBe(90); + }); + }); + + it("distributes fractional remainders using Hamilton-Hare Largest Remainder Method", () => { + // Let n = 3, targetBps = 1000, minFloorBps = 100 + // unreserved = 700 + // downloads = [100, 200, 400], sum = 700 + // quotas: + // dep 0: (100 / 700) * 700 = 100.0 (remainder 0) + // dep 1: (200 / 700) * 700 = 200.0 (remainder 0) + // dep 2: (400 / 700) * 700 = 400.0 (remainder 0) + // alloc = [100 + 100, 100 + 200, 100 + 400] = [200, 300, 500] + const weights = allocateWeightsByDownloads([100, 200, 400], 1000, 100); + expect(weights).toEqual([200, 300, 500]); + expect(weights.reduce((a, b) => a + b, 0)).toBe(1000); + }); +}); + +describe("download count fetchers", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("fetchCrateDownloads parses crates.io JSON response", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + crate: { + downloads: 482910, + }, + }), + } as unknown as Response); + + const downloads = await fetchCrateDownloads("soroban-sdk"); + expect(downloads).toBe(482910); + expect(globalThis.fetch).toHaveBeenCalledWith("https://crates.io/api/v1/crates/soroban-sdk"); + globalThis.fetch = originalFetch; + }); + + it("fetchCrateDownloads returns 0 on HTTP failure", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + } as unknown as Response); + + const downloads = await fetchCrateDownloads("unknown-crate"); + expect(downloads).toBe(0); + globalThis.fetch = originalFetch; + }); + + it("fetchNpmDownloads parses api.npmjs.org JSON response", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + downloads: 125099, + package: "express", + }), + } as unknown as Response); + + const downloads = await fetchNpmDownloads("express"); + expect(downloads).toBe(125099); + expect(globalThis.fetch).toHaveBeenCalledWith("https://api.npmjs.org/downloads/point/last-month/express"); + globalThis.fetch = originalFetch; + }); + + it("fetchPackageDownloads dispatches correctly according to ecosystem", async () => { + globalThis.fetch = vi.fn().mockImplementation(async (url: string) => { + if (url.includes("crates.io")) { + return { + ok: true, + json: async () => ({ crate: { downloads: 500 } }), + }; + } + return { + ok: true, + json: async () => ({ downloads: 1500 }), + }; + }); + + const crateDl = await fetchPackageDownloads({ name: "serde", ecosystem: "cargo" }); + const npmDl = await fetchPackageDownloads({ name: "lodash", ecosystem: "npm" }); + + expect(crateDl).toBe(500); + expect(npmDl).toBe(1500); + globalThis.fetch = originalFetch; + }); + + it("suggestDependencyWeights aggregates downloads in parallel and calculates weights", async () => { + globalThis.fetch = vi.fn().mockImplementation(async (url: string) => { + if (url.includes("soroban-sdk")) { + return { ok: true, json: async () => ({ crate: { downloads: 3000 } }) }; + } + if (url.includes("ed25519-dalek")) { + return { ok: true, json: async () => ({ crate: { downloads: 7000 } }) }; + } + return { ok: false }; + }); + + const deps: Pick[] = [ + { name: "soroban-sdk", ecosystem: "cargo" }, + { name: "ed25519-dalek", ecosystem: "cargo" }, + ]; + + const weights = await suggestDependencyWeights(deps, 9000, 50); + expect(weights.length).toBe(2); + expect(weights.reduce((a, b) => a + b, 0)).toBe(9000); + expect(weights[1]).toBeGreaterThan(weights[0]); + globalThis.fetch = originalFetch; + }); +}); diff --git a/frontend/src/lib/manifest.ts b/frontend/src/lib/manifest.ts index d761914..570c720 100644 --- a/frontend/src/lib/manifest.ts +++ b/frontend/src/lib/manifest.ts @@ -557,3 +557,137 @@ export async function resolveMaintainerFundingAddress( return null; } } + + +// ---------- Download Count Weight Suggestion ---------- + +/** + * Pure weight allocator based on package downloads using the Hamilton-Hare Largest Remainder Method. + * Guarantees that the sum of allocated basis points strictly equals `targetBps` with no drift. + * Ensures each package receives at least `minFloorBps` when feasible. + * Falls back gracefully to `equalWeights` when all downloads are 0 or empty. + */ +export function allocateWeightsByDownloads( + downloads: number[], + targetBps = 9000, + minFloorBps = 50 +): number[] { + const n = downloads.length; + if (n === 0) return []; + if (n === 1) return [targetBps]; + + // If even allocating the minimum floor exceeds targetBps, scale down floor + const effectiveFloor = n * minFloorBps <= targetBps ? minFloorBps : Math.floor(targetBps / n); + + // Clean downloads array (replace negatives or NaNs with 0) + const cleanDownloads = downloads.map((d) => (typeof d === "number" && !isNaN(d) && d > 0 ? d : 0)); + const totalDownloads = cleanDownloads.reduce((acc, d) => acc + d, 0); + + // If no package has any downloads, distribute evenly + if (totalDownloads <= 0) { + return equalWeights(n, targetBps); + } + + const unreservedBps = targetBps - n * effectiveFloor; + if (unreservedBps <= 0) { + return equalWeights(n, targetBps); + } + + // Initial floor allocation + const alloc = new Array(n).fill(effectiveFloor); + + // Calculate proportional quotas and split into integer and fractional parts + const fractions: { index: number; remainder: number; downloads: number }[] = []; + let allocatedAboveFloor = 0; + + for (let i = 0; i < n; i++) { + const rawQuota = (cleanDownloads[i] / totalDownloads) * unreservedBps; + const intQuota = Math.floor(rawQuota); + const remainder = rawQuota - intQuota; + + alloc[i] += intQuota; + allocatedAboveFloor += intQuota; + fractions.push({ index: i, remainder, downloads: cleanDownloads[i] }); + } + + let remainingBps = unreservedBps - allocatedAboveFloor; + + // Hamilton-Hare largest remainder distribution: + // Sort descending by remainder fraction. Tie-break by highest download count, then original index. + fractions.sort((a, b) => { + if (Math.abs(b.remainder - a.remainder) > 1e-9) { + return b.remainder - a.remainder; + } + if (b.downloads !== a.downloads) { + return b.downloads - a.downloads; + } + return a.index - b.index; + }); + + for (let i = 0; i < remainingBps && i < fractions.length; i++) { + alloc[fractions[i].index] += 1; + } + + return alloc; +} + +/** + * Fetches all-time total downloads for a Rust crate from crates.io. + */ +export async function fetchCrateDownloads(name: string): Promise { + try { + const res = await fetch(`https://crates.io/api/v1/crates/${encodeURIComponent(name)}`); + if (!res.ok) return 0; + const json = await res.json(); + return Number(json?.crate?.downloads) || 0; + } catch { + return 0; + } +} + +/** + * Fetches last-month download counts for an npm package from api.npmjs.org. + */ +export async function fetchNpmDownloads(name: string): Promise { + try { + const res = await fetch(`https://api.npmjs.org/downloads/point/last-month/${encodeURIComponent(name)}`); + if (!res.ok) return 0; + const json = await res.json(); + return Number(json?.downloads) || 0; + } catch { + return 0; + } +} + +/** + * Fetches download counts for a dependency across crates.io or npm. + */ +export async function fetchPackageDownloads(dep: Pick): Promise { + if (dep.ecosystem === "cargo") { + return fetchCrateDownloads(dep.name); + } else { + return fetchNpmDownloads(dep.name); + } +} + +/** + * Queries download metrics for a list of dependencies in parallel and calculates + * proportional split weights in basis points (strictly summing to `targetBps`). + */ +export async function suggestDependencyWeights( + deps: Pick[], + targetBps = 9000, + minFloorBps = 50 +): Promise { + if (deps.length === 0) return []; + if (deps.length === 1) return [targetBps]; + + try { + const downloadCounts = await Promise.all( + deps.map((dep) => fetchPackageDownloads(dep).catch(() => 0)) + ); + return allocateWeightsByDownloads(downloadCounts, targetBps, minFloorBps); + } catch { + return equalWeights(deps.length, targetBps); + } +} diff --git a/frontend/src/pages/Cascades.tsx b/frontend/src/pages/Cascades.tsx index 6575fdd..dda9376 100644 --- a/frontend/src/pages/Cascades.tsx +++ b/frontend/src/pages/Cascades.tsx @@ -4,7 +4,7 @@ import { useApp } from "../state"; import { splitter, type Payout } from "../lib/contracts"; import { config, XLM_SAC } from "../lib/config"; import { cleanError, sv } from "../lib/soroban"; -import { equalWeights, fetchRepoManifest, parseManifest, registryUrl, resolveMaintainerFundingAddress, type Dependency, type Manifest } from "../lib/manifest"; +import { equalWeights, fetchRepoManifest, parseManifest, registryUrl, resolveMaintainerFundingAddress, suggestDependencyWeights, type Dependency, type Manifest } from "../lib/manifest"; import { fromUnits, shortAddr, toUnits } from "../lib/format"; import { Banner } from "../components/Shell"; import { CopyButton, Empty, Field, Icon, SectionTitle, Spinner } from "../components/ui"; @@ -44,6 +44,7 @@ export function CascadesPage() { const [onChain, setOnChain] = useState(null); const [loadingChain, setLoadingChain] = useState(false); const [includeDev, setIncludeDev] = useState(false); + const [suggestingWeights, setSuggestingWeights] = useState(false); // Preview / distribute const [owner, setOwner] = useState(config.readSource); // demo table until a wallet connects @@ -315,9 +316,28 @@ export function CascadesPage() { icon="lan" aside={ rows.length > 0 && ( - +
+ + +
) } >