{
+ function createMockContainer(elements: { tag: string; id: string; disabled?: boolean }[]) {
+ const container = {
+ contains: vi.fn((el) => elements.some((e) => e.id === el?.id)),
+ querySelectorAll: vi.fn((_selector: string) => {
+ return elements.map((item) => ({
+ id: item.id,
+ tagName: item.tag.toUpperCase(),
+ focus: vi.fn(),
+ getAttribute: vi.fn((attr: string) => (attr === "disabled" && item.disabled ? "" : null)),
+ style: {},
+ }));
+ }),
+ } as unknown as HTMLElement;
+ return container;
+ }
+
+ it("ignores non-Tab key events", () => {
+ const container = createMockContainer([{ tag: "button", id: "btn1" }]);
+ const preventDefault = vi.fn();
+ const handled = handleFocusTrap({
+ container,
+ event: { key: "Enter", shiftKey: false, preventDefault },
+ activeElement: null,
+ });
+
+ expect(handled).toBe(false);
+ expect(preventDefault).not.toHaveBeenCalled();
+ });
+
+ it("wraps from last to first on Tab", () => {
+ const elements = [
+ { id: "close-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
+ { id: "save-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
+ ];
+ const container = {
+ contains: vi.fn(() => true),
+ querySelectorAll: vi.fn(() => elements),
+ } as unknown as HTMLElement;
+
+ const preventDefault = vi.fn();
+ const handled = handleFocusTrap({
+ container,
+ event: { key: "Tab", shiftKey: false, preventDefault },
+ activeElement: elements[1] as any, // Last element
+ });
+
+ expect(handled).toBe(true);
+ expect(preventDefault).toHaveBeenCalled();
+ expect(elements[0].focus).toHaveBeenCalled();
+ });
+
+ it("wraps from first to last on Shift+Tab", () => {
+ const elements = [
+ { id: "close-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
+ { id: "save-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
+ ];
+ const container = {
+ contains: vi.fn(() => true),
+ querySelectorAll: vi.fn(() => elements),
+ } as unknown as HTMLElement;
+
+ const preventDefault = vi.fn();
+ const handled = handleFocusTrap({
+ container,
+ event: { key: "Tab", shiftKey: true, preventDefault },
+ activeElement: elements[0] as any, // First element
+ });
+
+ expect(handled).toBe(true);
+ expect(preventDefault).toHaveBeenCalled();
+ expect(elements[1].focus).toHaveBeenCalled();
+ });
+
+ it("focuses first element if focus is outside container on Tab", () => {
+ const elements = [
+ { id: "close-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
+ { id: "save-btn", tagName: "BUTTON", focus: vi.fn(), style: {} },
+ ];
+ const container = {
+ contains: vi.fn(() => false),
+ querySelectorAll: vi.fn(() => elements),
+ } as unknown as HTMLElement;
+
+ const preventDefault = vi.fn();
+ const handled = handleFocusTrap({
+ container,
+ event: { key: "Tab", shiftKey: false, preventDefault },
+ activeElement: { id: "outside" } as any,
+ });
+
+ expect(handled).toBe(true);
+ expect(preventDefault).toHaveBeenCalled();
+ expect(elements[0].focus).toHaveBeenCalled();
+ });
+
+ it("prevents default if no focusable elements are present", () => {
+ const container = {
+ contains: vi.fn(() => true),
+ querySelectorAll: vi.fn(() => []),
+ } as unknown as HTMLElement;
+
+ const preventDefault = vi.fn();
+ const handled = handleFocusTrap({
+ container,
+ event: { key: "Tab", shiftKey: false, preventDefault },
+ activeElement: null,
+ });
+
+ expect(handled).toBe(true);
+ expect(preventDefault).toHaveBeenCalled();
+ });
+});
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/focus.ts b/frontend/src/lib/focus.ts
new file mode 100644
index 0000000..2d6c0e7
--- /dev/null
+++ b/frontend/src/lib/focus.ts
@@ -0,0 +1,53 @@
+export const FOCUSABLE_SELECTOR =
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
+
+export interface FocusTrapOptions {
+ container: HTMLElement;
+ event: { key: string; shiftKey: boolean; preventDefault: () => void };
+ activeElement: Element | null;
+}
+
+/**
+ * Handles Tab and Shift+Tab key navigation to keep focus trapped within the container.
+ * Returns true if the event was handled (trapped) and default prevented.
+ */
+export function handleFocusTrap({
+ container,
+ event,
+ activeElement,
+}: FocusTrapOptions): boolean {
+ if (event.key !== "Tab") return false;
+
+ const focusables = Array.from(
+ container.querySelectorAll(FOCUSABLE_SELECTOR)
+ ).filter((el) => {
+ // In JSDOM / headless, offsetParent might be null, so check display/visibility if needed
+ const style = (el as any).style;
+ if (style && (style.display === "none" || style.visibility === "hidden")) return false;
+ return true;
+ });
+
+ if (focusables.length === 0) {
+ event.preventDefault();
+ return true;
+ }
+
+ const first = focusables[0];
+ const last = focusables[focusables.length - 1];
+
+ if (event.shiftKey) {
+ if (activeElement === first || !container.contains(activeElement)) {
+ event.preventDefault();
+ last.focus();
+ return true;
+ }
+ } else {
+ if (activeElement === last || !container.contains(activeElement)) {
+ event.preventDefault();
+ first.focus();
+ return true;
+ }
+ }
+
+ return false;
+}
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