From 7c946281e216c95bf63f7d184b89de50c668fd2d Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Wed, 13 May 2026 10:37:57 +1000 Subject: [PATCH] feat: add dry-run plugin scan jobs --- convex/_generated/api.d.ts | 4 + convex/crons.ts | 7 + convex/httpApiV1.handlers.test.ts | 843 +++++ convex/httpApiV1/packagesV1.ts | 322 ++ .../lib/packageDryRunFilesystemScan.test.ts | 1389 ++++++++ convex/lib/packageDryRunFilesystemScan.ts | 3008 +++++++++++++++++ convex/packageDryRunScans.test.ts | 2482 ++++++++++++++ convex/packageDryRunScans.ts | 1202 +++++++ convex/schema.ts | 93 + docs/http-api.md | 133 +- packages/clawhub-mod/README.md | 16 + packages/clawhub-mod/src/cli.ts | 80 +- .../clawhub-mod/src/commands/packages.test.ts | 760 +++++ packages/clawhub-mod/src/commands/packages.ts | 447 ++- packages/clawhub/src/cli/ui.test.ts | 8 +- packages/clawhub/src/cli/ui.ts | 38 +- packages/clawhub/src/schema/packages.ts | 114 + packages/clawhub/src/schema/textFiles.test.ts | 2 + packages/clawhub/src/schema/textFiles.ts | 2 + packages/clawhub/test/cliCommandTestKit.ts | 37 +- packages/schema/dist/packages.d.ts | 159 + packages/schema/dist/packages.js | 91 + packages/schema/dist/packages.js.map | 2 +- packages/schema/dist/textFiles.d.ts | 2 +- packages/schema/dist/textFiles.js | 2 + packages/schema/dist/textFiles.js.map | 2 +- packages/schema/src/packages.ts | 114 + packages/schema/src/textFiles.test.ts | 4 + packages/schema/src/textFiles.ts | 2 + specs/README.md | 1 + specs/package-dry-run-scans.md | 50 + 31 files changed, 11405 insertions(+), 11 deletions(-) create mode 100644 convex/lib/packageDryRunFilesystemScan.test.ts create mode 100644 convex/lib/packageDryRunFilesystemScan.ts create mode 100644 convex/packageDryRunScans.test.ts create mode 100644 convex/packageDryRunScans.ts create mode 100644 packages/clawhub-mod/src/commands/packages.test.ts create mode 100644 specs/package-dry-run-scans.md diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index c481435e58..9e8e241121 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -71,6 +71,7 @@ import type * as lib_moderation from "../lib/moderation.js"; import type * as lib_moderationEngine from "../lib/moderationEngine.js"; import type * as lib_moderationReasonCodes from "../lib/moderationReasonCodes.js"; import type * as lib_openaiResponse from "../lib/openaiResponse.js"; +import type * as lib_packageDryRunFilesystemScan from "../lib/packageDryRunFilesystemScan.js"; import type * as lib_packageRegistry from "../lib/packageRegistry.js"; import type * as lib_packageSearchDigest from "../lib/packageSearchDigest.js"; import type * as lib_packageSecurity from "../lib/packageSecurity.js"; @@ -107,6 +108,7 @@ import type * as maintenance from "../maintenance.js"; import type * as model_packages_rescans from "../model/packages/rescans.js"; import type * as model_rescans_policy from "../model/rescans/policy.js"; import type * as model_skills_rescans from "../model/skills/rescans.js"; +import type * as packageDryRunScans from "../packageDryRunScans.js"; import type * as packagePublishTokens from "../packagePublishTokens.js"; import type * as packages from "../packages.js"; import type * as publishers from "../publishers.js"; @@ -203,6 +205,7 @@ declare const fullApi: ApiFromModules<{ "lib/moderationEngine": typeof lib_moderationEngine; "lib/moderationReasonCodes": typeof lib_moderationReasonCodes; "lib/openaiResponse": typeof lib_openaiResponse; + "lib/packageDryRunFilesystemScan": typeof lib_packageDryRunFilesystemScan; "lib/packageRegistry": typeof lib_packageRegistry; "lib/packageSearchDigest": typeof lib_packageSearchDigest; "lib/packageSecurity": typeof lib_packageSecurity; @@ -239,6 +242,7 @@ declare const fullApi: ApiFromModules<{ "model/packages/rescans": typeof model_packages_rescans; "model/rescans/policy": typeof model_rescans_policy; "model/skills/rescans": typeof model_skills_rescans; + packageDryRunScans: typeof packageDryRunScans; packagePublishTokens: typeof packagePublishTokens; packages: typeof packages; publishers: typeof publishers; diff --git a/convex/crons.ts b/convex/crons.ts index 1b4ab7c213..05787a6636 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -72,6 +72,13 @@ crons.interval( { batchSize: 100 }, ); +crons.interval( + "package-dry-run-scan-prune", + { hours: 1 }, + internal.packageDryRunScans.prunePackageDryRunScansInternal, + {}, +); + // Daily re-scan of all active skills at 3am UTC crons.daily("vt-daily-rescan", { hourUTC: 3, minuteUTC: 0 }, internal.vt.rescanActiveSkills, {}); diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index d76e6e246e..f4d12443f3 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -38,6 +38,16 @@ const { __handlers } = await import("./httpApiV1"); type ActionCtx = import("./_generated/server").ActionCtx; +const packageDryRunScanInternalRefs = ( + internal as unknown as { + packageDryRunScans: { + createPackageDryRunScanJobForUserInternal: unknown; + getPackageDryRunScanJobForUserInternal: unknown; + listPackageDryRunScanResultsForUserInternal: unknown; + }; + } +).packageDryRunScans; + type RateLimitArgs = { key: string; limit: number; windowMs: number }; function isRateLimitArgs(args: unknown): args is RateLimitArgs { @@ -3844,6 +3854,48 @@ describe("httpApiV1 handlers", () => { }); }); + it("packages detail can serve a package named dry-run-scans", async () => { + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if ("name" in args) { + expect(args.name).toBe("dry-run-scans"); + return { + package: { + _id: "packages:dry-run-scans", + name: "dry-run-scans", + displayName: "Dry Run Scans", + family: "code-plugin", + tags: {}, + latestReleaseId: "packageReleases:1", + channel: "community", + isOfficial: false, + summary: "Plugin summary", + latestVersion: "1.2.3", + stats: { downloads: 7, installs: 3, stars: 2, versions: 4 }, + createdAt: 1, + updatedAt: 2, + }, + latestRelease: null, + owner: { _id: "users:owner", handle: "owner", displayName: "Owner" }, + }; + } + return null; + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); + + const response = await __handlers.packagesGetRouterV1Handler( + makeCtx({ runQuery, runMutation }), + new Request("https://example.com/api/v1/packages/dry-run-scans"), + ); + + if (response.status !== 200) throw new Error(await response.text()); + await expect(response.json()).resolves.toMatchObject({ + package: { + name: "dry-run-scans", + latestVersion: "1.2.3", + }, + }); + }); + it("packages detail accepts double-encoded scoped package names", async () => { const runQuery = vi.fn(async (_query: unknown, args: Record) => { if ("name" in args) { @@ -5143,6 +5195,797 @@ describe("httpApiV1 handlers", () => { ); }); + it("package dry-run scan start creates an admin job", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + return { + jobId: "packageDryRunScanJobs:1", + status: "queued", + totalItems: 1, + targetSelectionDone: true, + }; + }); + + const response = await __handlers.packagesPostRouterV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: JSON.stringify({ + selector: { kind: "releaseIds", releaseIds: [" packageReleases:demo "] }, + }), + }), + ); + + if (response.status !== 200) throw new Error(await response.text()); + await expect(response.json()).resolves.toEqual({ + jobId: "packageDryRunScanJobs:1", + status: "queued", + totalItems: 1, + targetSelectionDone: true, + }); + expect(runMutation).toHaveBeenCalledWith( + packageDryRunScanInternalRefs.createPackageDryRunScanJobForUserInternal, + { + actorUserId: "users:admin", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + }, + ); + }); + + it("package dry-run scan start accepts explicit package names", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + return { + jobId: "packageDryRunScanJobs:1", + status: "queued", + totalItems: 1, + targetSelectionDone: true, + }; + }); + + const response = await __handlers.packagesPostRouterV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: JSON.stringify({ + selector: { kind: "packageNames", packageNames: [" demo-plugin "] }, + }), + }), + ); + + if (response.status !== 200) throw new Error(await response.text()); + expect(runMutation).toHaveBeenCalledWith( + packageDryRunScanInternalRefs.createPackageDryRunScanJobForUserInternal, + { + actorUserId: "users:admin", + selector: { kind: "packageNames", packageNames: ["demo-plugin"] }, + }, + ); + }); + + it("package dry-run scan start rejects seeded samples with fewer candidates than requested limit", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error(`unexpected mutation ${JSON.stringify(args)}`); + }); + + const response = await __handlers.packagesPostRouterV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: JSON.stringify({ + selector: { kind: "seededSample", seed: "fs-safe-v1", limit: 20, maxCandidates: 10 }, + }), + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toContain( + "selector.maxCandidates must be greater than or equal to selector.limit", + ); + }); + + it("package dry-run scan start rejects oversized seeded sample seeds", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error(`unexpected mutation ${JSON.stringify(args)}`); + }); + + const response = await __handlers.packagesPostRouterV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: JSON.stringify({ + selector: { kind: "seededSample", seed: "x".repeat(129), limit: 1, maxCandidates: 1 }, + }), + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toContain("selector.seed is limited to 128 characters"); + }); + + it("package dry-run scan start rejects invalid JSON", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error(`unexpected mutation ${JSON.stringify(args)}`); + }); + + const response = await __handlers.packagesPostRouterV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: "{", + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toBe("Invalid JSON"); + }); + + it("package dry-run scan start rejects selector fields that do not apply to the selector kind", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error(`unexpected mutation ${JSON.stringify(args)}`); + }); + + const response = await __handlers.packagesPostRouterV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: JSON.stringify({ + selector: { kind: "allActive", limit: 10 }, + }), + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toContain( + "selector.allActive has unexpected field limit", + ); + const nonRateCalls = runMutation.mock.calls.filter(([, args]) => !isRateLimitArgs(args)); + expect(nonRateCalls).toEqual([]); + }); + + it("package dry-run scan start rejects top-level fields that do not apply", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error(`unexpected mutation ${JSON.stringify(args)}`); + }); + + const response = await __handlers.packagesPostRouterV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: JSON.stringify({ + selector: { kind: "allActive" }, + limit: 10, + }), + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toContain("unexpected field limit"); + const nonRateCalls = runMutation.mock.calls.filter(([, args]) => !isRateLimitArgs(args)); + expect(nonRateCalls).toEqual([]); + }); + + it("package dry-run scan start rejects empty selector arrays before backend mutation", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error(`unexpected mutation ${JSON.stringify(args)}`); + }); + + const response = await __handlers.packagesPostRouterV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: JSON.stringify({ + selector: { kind: "releaseIds", releaseIds: [] }, + }), + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toContain( + "selector.releaseIds must be non-empty strings", + ); + const nonRateCalls = runMutation.mock.calls.filter(([, args]) => !isRateLimitArgs(args)); + expect(nonRateCalls).toEqual([]); + }); + + it("package dry-run scan start rejects oversized explicit selectors before backend mutation", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error(`unexpected mutation ${JSON.stringify(args)}`); + }); + + const response = await __handlers.packagesPostRouterV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: JSON.stringify({ + selector: { + kind: "packageNames", + packageNames: Array.from({ length: 201 }, (_, index) => `plugin-${index}`), + }, + }), + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toContain( + "selector.packageNames is limited to 200 packages", + ); + const nonRateCalls = runMutation.mock.calls.filter(([, args]) => !isRateLimitArgs(args)); + expect(nonRateCalls).toEqual([]); + }); + + it("package dry-run scan start rejects over-max selector sizes before backend mutation", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error(`unexpected mutation ${JSON.stringify(args)}`); + }); + + const response = await __handlers.packagesPostRouterV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: JSON.stringify({ + selector: { kind: "seededSample", seed: "fs-safe-v1", limit: 201, maxCandidates: 10 }, + }), + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toContain("selector.limit is limited to 200"); + const nonRateCalls = runMutation.mock.calls.filter(([, args]) => !isRateLimitArgs(args)); + expect(nonRateCalls).toEqual([]); + }); + + it("package dry-run scan start rejects fractional numeric selector fields", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error(`unexpected mutation ${JSON.stringify(args)}`); + }); + + const response = await __handlers.packagesPostRouterV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: JSON.stringify({ + selector: { kind: "seededSample", seed: "fs-safe-v1", limit: 1.5, maxCandidates: 10 }, + }), + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toContain("selector.limit must be a positive integer"); + const nonRateCalls = runMutation.mock.calls.filter(([, args]) => !isRateLimitArgs(args)); + expect(nonRateCalls).toEqual([]); + }); + + it("package dry-run scan start returns 500 for backend failures", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error("dry-run scan queue unavailable"); + }); + + const response = await __handlers.packagesPostRouterV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: JSON.stringify({ + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + }), + }), + ); + + expect(response.status).toBe(500); + await expect(response.text()).resolves.toBe("Package dry-run scan start failed"); + expect(runMutation).toHaveBeenCalledWith( + packageDryRunScanInternalRefs.createPackageDryRunScanJobForUserInternal, + { + actorUserId: "users:admin", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + }, + ); + }); + + it("package dry-run scan start returns 400 for backend argument validation failures", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error("ArgumentValidationError: Value does not match validator"); + }); + + const response = await __handlers.packagesPostRouterV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: JSON.stringify({ + selector: { kind: "releaseIds", releaseIds: ["not-a-release-id"] }, + }), + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toContain("ArgumentValidationError"); + }); + + it("package dry-run scan start returns 400 for selector content failures", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error("No active package releases matched the dry-run scan selector"); + }); + + const response = await __handlers.packagesPostRouterV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: JSON.stringify({ + selector: { kind: "packageNames", packageNames: ["missing-plugin"] }, + }), + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toBe( + "No active package releases matched the dry-run scan selector", + ); + }); + + it("package dry-run scan start returns 400 for unresolved explicit selectors", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error("Dry-run scan selector could not resolve packageNames: missing-plugin"); + }); + + const response = await __handlers.packagesPostRouterV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: JSON.stringify({ + selector: { kind: "packageNames", packageNames: ["missing-plugin"] }, + }), + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toBe( + "Dry-run scan selector could not resolve packageNames: missing-plugin", + ); + }); + + it("package dry-run scan start returns 400 for selector scan-limit failures", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error( + "Dry-run scan selector reached selection scan limit before collecting requested releases", + ); + }); + + const response = await __handlers.packagesPostRouterV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: JSON.stringify({ + selector: { kind: "latestActive", limit: 100 }, + }), + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toBe( + "Dry-run scan selector reached selection scan limit before collecting requested releases", + ); + }); + + it("package dry-run scan start returns 400 for reserved package names", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error( + 'Package name "publish" is reserved for ClawHub routes. Use a scoped name or choose a different package name.', + ); + }); + + const response = await __handlers.packagesPostRouterV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: JSON.stringify({ + selector: { kind: "packageNames", packageNames: ["publish"] }, + }), + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toContain("reserved for ClawHub routes"); + }); + + it("package dry-run scan start forbids non-admin api tokens", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:moderator", + user: { _id: "users:moderator", role: "moderator" }, + } as never); + let nonRateMutationCalls = 0; + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + nonRateMutationCalls += 1; + return null; + }); + + const response = await __handlers.packagesPostRouterV1Handler( + makeCtx({ runMutation }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, + body: JSON.stringify({ + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + }), + }), + ); + + expect(response.status).toBe(403); + expect(nonRateMutationCalls).toBe(0); + }); + + it("package dry-run scan reads forbid non-admin api tokens", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:moderator", + user: { _id: "users:moderator", role: "moderator" }, + } as never); + let nonRateQueryCalls = 0; + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + nonRateQueryCalls += 1; + return null; + }); + + const statusResponse = await __handlers.packagesGetRouterV1Handler( + makeCtx({ runQuery }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans/packageDryRunScanJobs%3A1", { + headers: { Authorization: "Bearer clh_test" }, + }), + ); + const resultsResponse = await __handlers.packagesGetRouterV1Handler( + makeCtx({ runQuery }), + new Request( + "https://example.com/api/v1/packages/-/dry-run-scans/packageDryRunScanJobs%3A1/results", + { + headers: { Authorization: "Bearer clh_test" }, + }, + ), + ); + + expect(statusResponse.status).toBe(403); + expect(resultsResponse.status).toBe(403); + expect(nonRateQueryCalls).toBe(0); + }); + + it("package dry-run scan status reads an admin job", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + return { + jobId: "packageDryRunScanJobs:1", + status: "running", + totalItems: 2, + queuedItems: 1, + runningItems: 1, + completedItems: 0, + failedItems: 0, + skippedItems: 0, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_100, + }; + }); + + const response = await __handlers.packagesGetRouterV1Handler( + makeCtx({ runQuery }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans/packageDryRunScanJobs%3A1", { + headers: { Authorization: "Bearer clh_test" }, + }), + ); + + if (response.status !== 200) throw new Error(await response.text()); + await expect(response.json()).resolves.toMatchObject({ + jobId: "packageDryRunScanJobs:1", + status: "running", + totalItems: 2, + }); + expect(runQuery).toHaveBeenCalledWith( + packageDryRunScanInternalRefs.getPackageDryRunScanJobForUserInternal, + { + actorUserId: "users:admin", + jobId: "packageDryRunScanJobs:1", + }, + ); + }); + + it("package dry-run scan status returns 404 for missing jobs", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error("Package dry-run scan job not found"); + }); + + const response = await __handlers.packagesGetRouterV1Handler( + makeCtx({ runQuery }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans/packageDryRunScanJobs%3A1", { + headers: { Authorization: "Bearer clh_test" }, + }), + ); + + expect(response.status).toBe(404); + await expect(response.text()).resolves.toBe("Package dry-run scan job not found"); + }); + + it("package dry-run scan status returns 500 for backend lookup failures", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error("dry-run scan storage unavailable"); + }); + + const response = await __handlers.packagesGetRouterV1Handler( + makeCtx({ runQuery }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans/packageDryRunScanJobs%3A1", { + headers: { Authorization: "Bearer clh_test" }, + }), + ); + + expect(response.status).toBe(500); + await expect(response.text()).resolves.toBe("Package dry-run scan lookup failed"); + }); + + it("package dry-run scan status returns 400 for malformed job ids", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error("ArgumentValidationError: Value does not match validator"); + }); + + const response = await __handlers.packagesGetRouterV1Handler( + makeCtx({ runQuery }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans/not-a-job-id", { + headers: { Authorization: "Bearer clh_test" }, + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toBe("Package dry-run scan job id is invalid"); + }); + + it("package dry-run scan results lists admin job results", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + return { + jobStatus: "running", + jobDone: false, + partial: true, + items: [ + { + itemId: "packageDryRunScanResults:1", + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "completed", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_100, + }, + ], + nextCursor: "cursor-2", + done: false, + }; + }); + + const response = await __handlers.packagesGetRouterV1Handler( + makeCtx({ runQuery }), + new Request( + "https://example.com/api/v1/packages/-/dry-run-scans/packageDryRunScanJobs%3A1/results?cursor=cursor-1&limit=50", + { + headers: { Authorization: "Bearer clh_test" }, + }, + ), + ); + + if (response.status !== 200) throw new Error(await response.text()); + await expect(response.json()).resolves.toMatchObject({ + jobStatus: "running", + jobDone: false, + partial: true, + items: [{ itemId: "packageDryRunScanResults:1", status: "completed" }], + nextCursor: "cursor-2", + done: false, + }); + expect(runQuery).toHaveBeenCalledWith( + packageDryRunScanInternalRefs.listPackageDryRunScanResultsForUserInternal, + { + actorUserId: "users:admin", + jobId: "packageDryRunScanJobs:1", + cursor: "cursor-1", + limit: 50, + }, + ); + }); + + it("package dry-run scan results rejects malformed limits", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error(`unexpected query ${JSON.stringify(args)}`); + }); + + const response = await __handlers.packagesGetRouterV1Handler( + makeCtx({ runQuery }), + new Request( + "https://example.com/api/v1/packages/-/dry-run-scans/packageDryRunScanJobs%3A1/results?limit=10abc", + { + headers: { Authorization: "Bearer clh_test" }, + }, + ), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toContain("limit must be a positive integer"); + }); + + it("package dry-run scan results rejects malformed cursors", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error("InvalidCursor: malformed pagination cursor"); + }); + + const response = await __handlers.packagesGetRouterV1Handler( + makeCtx({ runQuery }), + new Request( + "https://example.com/api/v1/packages/-/dry-run-scans/packageDryRunScanJobs%3A1/results?cursor=not-a-cursor", + { + headers: { Authorization: "Bearer clh_test" }, + }, + ), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toBe("Package dry-run scan results: cursor is invalid"); + }); + + it("package dry-run scan results returns a static error for malformed job ids", async () => { + vi.mocked(requireApiTokenUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + throw new Error("ArgumentValidationError: Value does not match validator \u001b[31m"); + }); + + const response = await __handlers.packagesGetRouterV1Handler( + makeCtx({ runQuery }), + new Request("https://example.com/api/v1/packages/-/dry-run-scans/not-a-job-id/results", { + headers: { Authorization: "Bearer clh_test" }, + }), + ); + + expect(response.status).toBe(400); + await expect(response.text()).resolves.toBe("Package dry-run scan job id is invalid"); + }); + it("npm mirror packument lists only ClawPack-backed releases", async () => { const runQuery = vi.fn(async (_query: unknown, args: Record) => { if ("name" in args && !("paginationOpts" in args)) { diff --git a/convex/httpApiV1/packagesV1.ts b/convex/httpApiV1/packagesV1.ts index fdf7cc51b9..9338aee1c4 100644 --- a/convex/httpApiV1/packagesV1.ts +++ b/convex/httpApiV1/packagesV1.ts @@ -46,7 +46,9 @@ import { MAX_RAW_FILE_BYTES, getPathSegments, json, + requireAdminOrResponse, resolveTagsBatch, + parseJsonPayload, requireApiTokenUserOrResponse, requirePackagePublishAuthOrResponse, safeTextFileResponse, @@ -102,6 +104,11 @@ const internalRefs = internal as unknown as { backfillPackageArtifactKindsInternal: unknown; listPackageModerationQueueInternal: unknown; }; + packageDryRunScans: { + createPackageDryRunScanJobForUserInternal: unknown; + getPackageDryRunScanJobForUserInternal: unknown; + listPackageDryRunScanResultsForUserInternal: unknown; + }; packagePublishTokens: { createInternal: unknown; }; @@ -257,6 +264,224 @@ function parsePackageOfficialMigrationPhase( return null; } +type PackageDryRunScanStartPayload = { + selector: + | { kind: "releaseIds"; releaseIds: string[] } + | { kind: "packageNames"; packageNames: string[] } + | { kind: "latestActive"; limit: number } + | { kind: "allActive" } + | { kind: "seededSample"; seed: string; limit: number; maxCandidates: number }; +}; + +const PACKAGE_DRY_RUN_SCAN_MAX_EXPLICIT_SELECTORS = 200; +const PACKAGE_DRY_RUN_SCAN_MAX_LIMIT = 200; +const PACKAGE_DRY_RUN_SCAN_MAX_CANDIDATES = 1_000; +const PACKAGE_DRY_RUN_SCAN_MAX_SEED_CHARS = 128; + +function parsePackageDryRunScanStartPayload(value: unknown): PackageDryRunScanStartPayload { + if (!isObjectRecord(value)) throw new Error("Package dry-run scan payload: expected object"); + assertPackageDryRunScanPayloadKeys(value, ["selector"]); + const selector = value.selector; + if (!isObjectRecord(selector)) { + throw new Error("Package dry-run scan payload: selector must be an object"); + } + if (selector.kind === "releaseIds") { + assertPackageDryRunScanSelectorKeys(selector, ["kind", "releaseIds"], "releaseIds"); + if ( + !Array.isArray(selector.releaseIds) || + selector.releaseIds.length === 0 || + !selector.releaseIds.every(isNonEmptyString) + ) { + throw new Error( + "Package dry-run scan payload: selector.releaseIds must be non-empty strings", + ); + } + if (selector.releaseIds.length > PACKAGE_DRY_RUN_SCAN_MAX_EXPLICIT_SELECTORS) { + throw new Error( + `Package dry-run scan payload: selector.releaseIds is limited to ${PACKAGE_DRY_RUN_SCAN_MAX_EXPLICIT_SELECTORS} releases`, + ); + } + return { + selector: { kind: "releaseIds", releaseIds: selector.releaseIds.map((id) => id.trim()) }, + }; + } + if (selector.kind === "packageNames") { + assertPackageDryRunScanSelectorKeys(selector, ["kind", "packageNames"], "packageNames"); + if ( + !Array.isArray(selector.packageNames) || + selector.packageNames.length === 0 || + !selector.packageNames.every(isNonEmptyString) + ) { + throw new Error( + "Package dry-run scan payload: selector.packageNames must be non-empty strings", + ); + } + if (selector.packageNames.length > PACKAGE_DRY_RUN_SCAN_MAX_EXPLICIT_SELECTORS) { + throw new Error( + `Package dry-run scan payload: selector.packageNames is limited to ${PACKAGE_DRY_RUN_SCAN_MAX_EXPLICIT_SELECTORS} packages`, + ); + } + return { + selector: { + kind: "packageNames", + packageNames: selector.packageNames.map((name) => name.trim()), + }, + }; + } + if (selector.kind === "latestActive") { + assertPackageDryRunScanSelectorKeys(selector, ["kind", "limit"], "latestActive"); + return { + selector: { + kind: "latestActive", + limit: parsePackageDryRunPositiveInteger( + selector.limit, + "selector.limit", + PACKAGE_DRY_RUN_SCAN_MAX_LIMIT, + ), + }, + }; + } + if (selector.kind === "allActive") { + assertPackageDryRunScanSelectorKeys(selector, ["kind"], "allActive"); + return { selector: { kind: "allActive" } }; + } + if (selector.kind === "seededSample") { + assertPackageDryRunScanSelectorKeys( + selector, + ["kind", "seed", "limit", "maxCandidates"], + "seededSample", + ); + if (!isNonEmptyString(selector.seed)) { + throw new Error("Package dry-run scan payload: selector.seed must be a string"); + } + const seed = selector.seed.trim(); + if (seed.length > PACKAGE_DRY_RUN_SCAN_MAX_SEED_CHARS) { + throw new Error( + `Package dry-run scan payload: selector.seed is limited to ${PACKAGE_DRY_RUN_SCAN_MAX_SEED_CHARS} characters`, + ); + } + const limit = parsePackageDryRunPositiveInteger( + selector.limit, + "selector.limit", + PACKAGE_DRY_RUN_SCAN_MAX_LIMIT, + ); + const maxCandidates = parsePackageDryRunPositiveInteger( + selector.maxCandidates, + "selector.maxCandidates", + PACKAGE_DRY_RUN_SCAN_MAX_CANDIDATES, + ); + if (maxCandidates < limit) { + throw new Error( + "Package dry-run scan payload: selector.maxCandidates must be greater than or equal to selector.limit", + ); + } + return { + selector: { + kind: "seededSample", + seed, + limit, + maxCandidates, + }, + }; + } + throw new Error("Package dry-run scan payload: selector.kind is invalid"); +} + +function assertPackageDryRunScanPayloadKeys( + payload: Record, + allowedKeys: readonly string[], +) { + const allowed = new Set(allowedKeys); + const unexpected = Object.keys(payload).filter((key) => !allowed.has(key)); + if (unexpected.length > 0) { + throw new Error(`Package dry-run scan payload: unexpected field ${unexpected[0]}`); + } +} + +function parsePackageDryRunPositiveInteger(value: unknown, label: string, max: number) { + if (typeof value !== "number" || !Number.isInteger(value) || value < 1) { + throw new Error(`Package dry-run scan payload: ${label} must be a positive integer`); + } + if (value > max) { + throw new Error(`Package dry-run scan payload: ${label} is limited to ${max}`); + } + return value; +} + +function parsePackageDryRunResultsLimit(value: string | null) { + if (value === null || value === "") return 100; + if (!/^[1-9]\d*$/.test(value)) { + throw new Error("Package dry-run scan results: limit must be a positive integer"); + } + const limit = Number(value); + if (limit > 500) { + throw new Error("Package dry-run scan results: limit must be at most 500"); + } + return limit; +} + +function isPackageDryRunClientInputError(message: string) { + return ( + message.includes("ArgumentValidationError") || + message.includes("Value does not match validator") || + message.includes("Invalid id") || + message.includes("invalid id") || + message.includes("does not match the expected format") || + message.includes("Package name required") || + message.includes("Package name must be lowercase and npm-safe") || + message.includes("reserved for ClawHub routes") || + message.includes("Package dry-run scan payload:") || + message.includes("Package dry-run scan results:") || + message.includes("Dry-run scan selector reached selection scan limit") || + message.includes("Dry-run scan selector could not resolve") || + message.includes("limit must be at most") || + message.includes("maxCandidates must be greater than or equal to limit") || + message.includes("No active package releases matched the dry-run scan selector") + ); +} + +function isPackageDryRunJobIdError(message: string) { + return ( + message.includes("ArgumentValidationError") || + message.includes("Value does not match validator") || + message.includes("Invalid id") || + message.includes("invalid id") || + message.includes("does not match the expected format") + ); +} + +function isPackageDryRunResultsCursorError(message: string) { + return ( + message.includes("InvalidCursor") || + message.includes("Invalid cursor") || + message.includes("invalid cursor") || + message.includes("Invalid pagination cursor") || + message.includes("pagination cursor is invalid") + ); +} + +function assertPackageDryRunScanSelectorKeys( + selector: Record, + allowedKeys: readonly string[], + kind: string, +) { + const allowed = new Set(allowedKeys); + const unexpected = Object.keys(selector).filter((key) => !allowed.has(key)); + if (unexpected.length > 0) { + throw new Error( + `Package dry-run scan payload: selector.${kind} has unexpected field ${unexpected[0]}`, + ); + } +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + type PackageListQueryArgs = { family?: "skill" | "code-plugin" | "bundle-plugin"; channel?: "official" | "community" | "private"; @@ -1436,6 +1661,45 @@ export async function mintPublishTokenV1Handler(ctx: ActionCtx, request: Request export async function packagesPostRouterV1Handler(ctx: ActionCtx, request: Request) { const segments = getPathSegments(request, "/api/v1/packages/"); + if (segments[0] === "-" && segments[1] === "dry-run-scans" && segments.length === 2) { + const rate = await applyRateLimit(ctx, request, "write"); + if (!rate.ok) return rate.response; + const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers); + if (!auth.ok) return auth.response; + const admin = requireAdminOrResponse(auth.user, rate.headers); + if (!admin.ok) return admin.response; + + const payload = await parseJsonPayload(request, rate.headers); + if (!payload.ok) return payload.response; + + let body: PackageDryRunScanStartPayload; + try { + body = parsePackageDryRunScanStartPayload(payload.payload); + } catch (error) { + return text( + error instanceof Error ? error.message : "Package dry-run scan payload is invalid", + 400, + rate.headers, + ); + } + + try { + const result = await runMutationRef( + ctx, + internalRefs.packageDryRunScans.createPackageDryRunScanJobForUserInternal, + { + actorUserId: auth.userId, + selector: body.selector, + }, + ); + return json(result, 200, rate.headers); + } catch (error) { + const message = error instanceof Error ? error.message : "Package dry-run scan start failed"; + if (isPackageDryRunClientInputError(message)) return text(message, 400, rate.headers); + return text("Package dry-run scan start failed", 500, rate.headers); + } + } + if (segments[0] === "backfill" && segments[1] === "artifacts" && segments.length === 2) { const rate = await applyRateLimit(ctx, request, "write"); if (!rate.ok) return rate.response; @@ -2101,6 +2365,64 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques return await searchPackages(ctx, request, { includeSkills: true }); } + if ( + segments[0] === "-" && + segments[1] === "dry-run-scans" && + segments[2] && + segments.length <= 4 + ) { + const rate = await applyRateLimit(ctx, request, "read"); + if (!rate.ok) return rate.response; + const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers); + if (!auth.ok) return auth.response; + const admin = requireAdminOrResponse(auth.user, rate.headers); + if (!admin.ok) return admin.response; + + try { + if (segments[3] === "results") { + const url = new URL(request.url); + const limit = parsePackageDryRunResultsLimit(url.searchParams.get("limit")); + const result = await runQueryRef( + ctx, + internalRefs.packageDryRunScans.listPackageDryRunScanResultsForUserInternal, + { + actorUserId: auth.userId, + jobId: segments[2], + cursor: url.searchParams.get("cursor") ?? null, + limit, + }, + ); + return json(result, 200, rate.headers); + } + + if (segments.length === 3) { + const result = await runQueryRef( + ctx, + internalRefs.packageDryRunScans.getPackageDryRunScanJobForUserInternal, + { + actorUserId: auth.userId, + jobId: segments[2], + }, + ); + return json(result, 200, rate.headers); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Package dry-run scan lookup failed"; + if (message.includes("Package dry-run scan job not found")) { + return text("Package dry-run scan job not found", 404, rate.headers); + } + if (isPackageDryRunJobIdError(message)) { + return text("Package dry-run scan job id is invalid", 400, rate.headers); + } + if (isPackageDryRunResultsCursorError(message)) { + return text("Package dry-run scan results: cursor is invalid", 400, rate.headers); + } + if (isPackageDryRunClientInputError(message)) return text(message, 400, rate.headers); + return text("Package dry-run scan lookup failed", 500, rate.headers); + } + return text("Not found", 404, rate.headers); + } + if (segments[0] === "moderation" && segments[1] === "queue" && segments.length === 2) { const rate = await applyRateLimit(ctx, request, "read"); if (!rate.ok) return rate.response; diff --git a/convex/lib/packageDryRunFilesystemScan.test.ts b/convex/lib/packageDryRunFilesystemScan.test.ts new file mode 100644 index 0000000000..c30386ab68 --- /dev/null +++ b/convex/lib/packageDryRunFilesystemScan.test.ts @@ -0,0 +1,1389 @@ +/* @vitest-environment node */ +import { describe, expect, it } from "vitest"; +import { + PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES, + buildPackageDryRunFilesystemEvidence, + runPackageDryRunFilesystemScan, + scanPackageDryRunFilesystemContent, +} from "./packageDryRunFilesystemScan"; + +describe("packageDryRunFilesystemScan", () => { + it("builds bounded deterministic evidence for raw fs and fs-safe findings", () => { + const longEvidence = [ + "import fs from 'node:fs';", + "const raw = fs.readFileSync('/Users/example/.ssh/id_rsa', 'utf8');", + "fetch('https://example.test/upload', { method: 'POST', body: raw });", + "const more = fs.readFileSync('/Users/example/.aws/credentials', 'utf8');", + ].join("\n"); + + const summary = buildPackageDryRunFilesystemEvidence( + [ + { + code: PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.FS_SAFE_USAGE, + severity: "info", + file: "src/safe.ts", + line: 20, + message: "Uses dry-run fs-safe path handling.", + evidence: "const target = resolveDryRunPath(args.path);", + }, + { + code: PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.RAW_FS_USAGE, + severity: "warn", + file: "src/raw-b.ts", + line: 9, + message: "Uses raw filesystem access.", + evidence: "fs.writeFileSync('/tmp/out', value);", + }, + { + code: PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.RAW_FS_USAGE, + severity: "warn", + file: "src/raw-a.ts", + line: 3, + message: "Reads a local credential path.", + evidence: longEvidence, + }, + { + code: "suspicious.unrelated", + severity: "warn", + file: "src/network.ts", + line: 1, + message: "Network finding.", + evidence: "fetch('https://example.test')", + }, + { + code: PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.RAW_FS_USAGE, + severity: "warn", + file: "src/raw-c.ts", + line: 40, + message: "Uses raw filesystem access.", + evidence: "fs.rmSync('/tmp/out', { recursive: true });", + }, + ], + { maxEvidenceItems: 2, maxEvidenceChars: 48 }, + ); + + expect(summary.rawFsUsage).toEqual({ + totalCount: 3, + returnedCount: 2, + omittedCount: 1, + truncatedEvidenceCount: 1, + reasonCode: PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.RAW_FS_USAGE, + evidence: [ + { + code: PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.RAW_FS_USAGE, + severity: "warn", + file: "src/raw-a.ts", + line: 3, + message: "Reads a local credential path.", + evidence: "import fs from 'node:fs';\nconst raw = fs.read...", + evidenceTruncated: true, + }, + { + code: PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.RAW_FS_USAGE, + severity: "warn", + file: "src/raw-b.ts", + line: 9, + message: "Uses raw filesystem access.", + evidence: "fs.writeFileSync('/tmp/out', value);", + evidenceTruncated: false, + }, + ], + }); + expect(summary.fsSafeUsage).toEqual({ + totalCount: 1, + returnedCount: 1, + omittedCount: 0, + truncatedEvidenceCount: 0, + reasonCode: PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.FS_SAFE_USAGE, + evidence: [ + { + code: PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.FS_SAFE_USAGE, + severity: "info", + file: "src/safe.ts", + line: 20, + message: "Uses dry-run fs-safe path handling.", + evidence: "const target = resolveDryRunPath(args.path);", + evidenceTruncated: false, + }, + ], + }); + }); + + it("bounds accumulated evidence while counting every finding during storage scans", async () => { + const content = Array.from( + { length: 12 }, + (_, index) => `const fs${index} = require('node:fs');`, + ).join("\n"); + const storage = { + get: async () => ({ + size: content.length, + text: async () => content, + }), + }; + + const summary = await runPackageDryRunFilesystemScan({ storage } as never, { + files: [ + { + path: "dist/many.js", + storageId: "storage:many", + size: content.length, + contentType: "application/javascript", + }, + ], + }); + + expect(summary.rawFsUsage.totalCount).toBe(12); + expect(summary.rawFsUsage.returnedCount).toBe(5); + expect(summary.rawFsUsage.omittedCount).toBe(7); + expect(summary.rawFsUsage.evidence.map((item) => item.line)).toEqual([1, 2, 3, 4, 5]); + }); + + it("detects raw fs usage and fs-safe usage from plugin code content", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/index.js", + [ + "import { writeFile } from 'node:fs/promises';", + "import { writeFileWithinRoot } from '@openclaw/fs-safe';", + "await writeFile('/tmp/example', payload);", + "await writeFileWithinRoot(root, 'example.txt', payload);", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.code)).toEqual([ + PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.RAW_FS_USAGE, + PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.FS_SAFE_USAGE, + PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.RAW_FS_USAGE, + PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.FS_SAFE_USAGE, + ]); + }); + + it("detects raw fs and fs-safe usage on the same line", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/index.js", + "import fs from 'node:fs'; import { readFileWithinRoot } from '@openclaw/fs-safe';", + ); + + expect(findings.map((finding) => finding.code)).toEqual([ + PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.RAW_FS_USAGE, + PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.FS_SAFE_USAGE, + ]); + }); + + it("does not count comments or string literals as filesystem usage", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/comments.js", + [ + "// import fs from 'node:fs';", + "const docs = \"fs.readFileSync('/tmp/example') and @openclaw/fs-safe\";", + "/* const fs = require('node:fs'); */", + "const rawPattern = /fs\\.readFileSync\\('/;", + "const safePattern = /@openclaw\\/fs-safe/;", + "function rawPattern() { return /fs\\.readFileSync/; }", + "function safePattern() { return /@openclaw\\/fs-safe/; }", + "function throwPattern() { throw /fs\\.readFileSync/; }", + "if (ok) /fs\\.readFileSync/.test(source);", + "const fs = makeVirtualFilesystem();", + "fs.readFile('/virtual/file');", + ].join("\n"), + ); + + expect(findings).toHaveLength(0); + }); + + it("keeps real evidence while ignoring string and comment lookalikes", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/real-and-lookalike.js", + [ + "import fs from 'node:fs';", + "import { readFileWithinRoot } from '@openclaw/fs-safe';", + "const docs = \"fs.readFileSync('/tmp/example') and readFileWithinRoot\";", + "// fs.writeFileSync('/tmp/example', payload);", + "fs.readFileSync('/tmp/real');", + "await readFileWithinRoot(root, 'real.txt');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "import { readFileWithinRoot } from '@openclaw/fs-safe';", + "fs.readFileSync('/tmp/real');", + "await readFileWithinRoot(root, 'real.txt');", + ]); + }); + + it("only counts fs-safe helper calls when they come from fs-safe modules", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/fs-safe-origin.js", + [ + "const readFileWithinRoot = makeLocalHelper();", + "readFileWithinRoot('/virtual/local');", + "import { readFileWithinRoot as readSafe } from '@openclaw/fs-safe';", + "import * as safeRuntime from 'openclaw/plugin-sdk/security-runtime';", + "const safeModule = require('openclaw/plugin-sdk/file-access-runtime');", + "const { writeFileWithinRoot: writeSafe } = safeModule;", + "const sanitize = safeRuntime.sanitizeUntrustedFileName;", + "await readSafe(root, 'real.txt');", + "await writeSafe(root, 'out.txt', payload);", + "sanitize(userInput);", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import { readFileWithinRoot as readSafe } from '@openclaw/fs-safe';", + "import * as safeRuntime from 'openclaw/plugin-sdk/security-runtime';", + "const safeModule = require('openclaw/plugin-sdk/file-access-runtime');", + "const sanitize = safeRuntime.sanitizeUntrustedFileName;", + "await readSafe(root, 'real.txt');", + "await writeSafe(root, 'out.txt', payload);", + "sanitize(userInput);", + ]); + }); + + it("detects fs-safe helper aliases after earlier declarators", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/fs-safe-comma.js", + [ + "import * as safeRuntime from '@openclaw/fs-safe';", + "const noop = 0, readSafe = safeRuntime.readFileWithinRoot, { writeFileWithinRoot: writeSafe } = safeRuntime;", + "await readSafe(root, 'in.txt');", + "await writeSafe(root, 'out.txt', payload);", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import * as safeRuntime from '@openclaw/fs-safe';", + "const noop = 0, readSafe = safeRuntime.readFileWithinRoot, { writeFileWithinRoot: writeSafe } = safeRuntime;", + "await readSafe(root, 'in.txt');", + "await writeSafe(root, 'out.txt', payload);", + ]); + }); + + it("detects filesystem calls inside template literal interpolation", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/template.js", + [ + "import fs from 'node:fs';", + "const rendered = `raw ${fs.readFileSync('/tmp/example', 'utf8')}`;", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "const rendered = `raw ${fs.readFileSync('/tmp/example', 'utf8')}`;", + ]); + }); + + it("does not count type-only fs imports as raw fs usage", () => { + const typeOnlyImportFindings = scanPackageDryRunFilesystemContent( + "src/index.mts", + "import type { Stats } from 'node:fs';\ntype PluginStats = Stats;", + ); + const namedTypeImportFindings = scanPackageDryRunFilesystemContent( + "src/index.cts", + "import { type Stats, type Dirent } from 'node:fs';\ntype PluginStats = Stats;", + ); + const importEqualsTypeFindings = scanPackageDryRunFilesystemContent( + "src/index.ts", + "import type fs = require('node:fs');\ntype Reader = typeof fs.readFileSync;", + ); + const namespaceTypeImportFindings = scanPackageDryRunFilesystemContent( + "src/index.ts", + "import type * as fs from 'node:fs';\ntype Reader = typeof fs.readFileSync;", + ); + + expect(typeOnlyImportFindings).toHaveLength(0); + expect(namedTypeImportFindings).toHaveLength(0); + expect(importEqualsTypeFindings).toHaveLength(0); + expect(namespaceTypeImportFindings).toHaveLength(0); + }); + + it("detects raw fs re-exports while ignoring type-only re-exports", () => { + const findings = scanPackageDryRunFilesystemContent( + "src/reexports.ts", + [ + "export type { Stats } from 'node:fs';", + "export { type Dirent } from 'node:fs';", + "export { readFile } from 'node:fs/promises';", + "export * as fs from 'node:fs';", + "export * from 'fs/promises';", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "export { readFile } from 'node:fs/promises';", + "export * as fs from 'node:fs';", + "export * from 'fs/promises';", + ]); + }); + + it("detects multiline raw fs imports and re-exports", () => { + const findings = scanPackageDryRunFilesystemContent( + "src/multiline-reexports.ts", + [ + "export type {", + " Stats,", + "} from 'node:fs';", + "export {", + " readFile,", + "} from 'node:fs/promises';", + "import {", + " writeFile,", + "} from 'node:fs/promises';", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "export {\n readFile,\n} from 'node:fs/promises';", + "import {\n writeFile,\n} from 'node:fs/promises';", + ]); + }); + + it("detects optional-chained raw fs calls", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/optional-chain.js", + [ + "import fs from 'node:fs';", + "import { readFile } from 'node:fs/promises';", + "fs?.readFileSync('/tmp/example');", + "await fs.promises?.readFile('/tmp/example');", + "await fs?.promises?.writeFile('/tmp/example', payload);", + "await readFile?.('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "import { readFile } from 'node:fs/promises';", + "fs?.readFileSync('/tmp/example');", + "await fs.promises?.readFile('/tmp/example');", + "await fs?.promises?.writeFile('/tmp/example', payload);", + "await readFile?.('/tmp/example');", + ]); + }); + + it("detects real fs imports on the same line as type-only fs imports", () => { + const findings = scanPackageDryRunFilesystemContent( + "src/mixed-type-import.js", + "import type fsTypes = require('node:fs'); import { type Stats } from 'node:fs'; const fs = require('node:fs'); fs.readFileSync('/tmp/example');", + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import type fsTypes = require('node:fs'); import { type Stats } from 'node:fs'; const fs = require('node:fs'); fs.readFileSync('/tmp/example');", + ]); + }); + + it("detects raw fs read and query APIs, not just mutating APIs", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/index.js", + [ + "const fs = require('node:fs');", + "const raw = fs.readFileSync('/tmp/example', 'utf8');", + "const entries = await fs.promises.readdir('/tmp');", + "const stream = fs.createReadStream('/tmp/example');", + "const exists = fs.existsSync('/tmp/example');", + "await fs.promises.copyFile('/tmp/a', '/tmp/b');", + "await fs.promises.realpath('/tmp/example');", + "await fs.promises.readlink('/tmp/link');", + "await fs.promises.chmod('/tmp/example', 0o600);", + "await fs.promises.chown('/tmp/example', uid, gid);", + "await fs.promises.utimes('/tmp/example', now, now);", + "fs.watch('/tmp/example', () => {});", + "await fs.promises.mkdtemp('/tmp/plugin-');", + "await fs.promises.truncate('/tmp/example', 0);", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "const fs = require('node:fs');", + "const raw = fs.readFileSync('/tmp/example', 'utf8');", + "const entries = await fs.promises.readdir('/tmp');", + "const stream = fs.createReadStream('/tmp/example');", + "const exists = fs.existsSync('/tmp/example');", + "await fs.promises.copyFile('/tmp/a', '/tmp/b');", + "await fs.promises.realpath('/tmp/example');", + "await fs.promises.readlink('/tmp/link');", + "await fs.promises.chmod('/tmp/example', 0o600);", + "await fs.promises.chown('/tmp/example', uid, gid);", + "await fs.promises.utimes('/tmp/example', now, now);", + "fs.watch('/tmp/example', () => {});", + "await fs.promises.mkdtemp('/tmp/plugin-');", + "await fs.promises.truncate('/tmp/example', 0);", + ]); + }); + + it("detects namespace aliases for raw fs APIs", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/index.mjs", + [ + "import * as nodeFs from 'node:fs';", + "import filesystem from 'node:fs/promises';", + "import { default as defaultFs } from 'node:fs';", + "import tsFs = require('node:fs');", + "const requiredFs = require('node:fs');", + "const importedFs = await import('node:fs/promises');", + "const promisedFs = require('node:fs').promises;", + "const promisedFromAlias = defaultFs.promises;", + "nodeFs.readFileSync('/tmp/example', 'utf8');", + "await nodeFs.promises.readdir('/tmp');", + "await filesystem.writeFile('/tmp/example', payload);", + "defaultFs.rmSync('/tmp/example');", + "tsFs.statSync('/tmp/example');", + "requiredFs.createReadStream('/tmp/example');", + "await importedFs.rm('/tmp/example');", + "await promisedFs.readFile('/tmp/example', 'utf8');", + "await promisedFromAlias.writeFile('/tmp/example', payload);", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import * as nodeFs from 'node:fs';", + "import filesystem from 'node:fs/promises';", + "import { default as defaultFs } from 'node:fs';", + "import tsFs = require('node:fs');", + "const requiredFs = require('node:fs');", + "const importedFs = await import('node:fs/promises');", + "const promisedFs = require('node:fs').promises;", + "nodeFs.readFileSync('/tmp/example', 'utf8');", + "await nodeFs.promises.readdir('/tmp');", + "await filesystem.writeFile('/tmp/example', payload);", + "defaultFs.rmSync('/tmp/example');", + "tsFs.statSync('/tmp/example');", + "requiredFs.createReadStream('/tmp/example');", + "await importedFs.rm('/tmp/example');", + "await promisedFs.readFile('/tmp/example', 'utf8');", + "await promisedFromAlias.writeFile('/tmp/example', payload);", + ]); + }); + + it("detects calls through direct raw fs member aliases", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/member-alias.js", + [ + "import fs from 'node:fs';", + "const readSync = fs.readFileSync;", + "const read = fs.promises.readFile;", + "const write = require('node:fs/promises').writeFile;", + "readSync('/tmp/example', 'utf8');", + "await read('/tmp/example', 'utf8');", + "await write('/tmp/example', payload);", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "const readSync = fs.readFileSync;", + "const read = fs.promises.readFile;", + "const write = require('node:fs/promises').writeFile;", + "readSync('/tmp/example', 'utf8');", + "await read('/tmp/example', 'utf8');", + "await write('/tmp/example', payload);", + ]); + }); + + it("detects calls through direct raw fs member aliases after earlier declarators", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/member-alias-comma.js", + [ + "import fs from 'node:fs';", + "const noop = 0, readSync = fs.readFileSync, read = fs.promises.readFile;", + "readSync('/tmp/example', 'utf8');", + "await read('/tmp/example', 'utf8');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "const noop = 0, readSync = fs.readFileSync, read = fs.promises.readFile;", + "readSync('/tmp/example', 'utf8');", + "await read('/tmp/example', 'utf8');", + ]); + }); + + it("detects promises namespace aliases from destructured fs imports", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/index.mjs", + [ + "import { promises as fs } from 'node:fs';", + "const { promises: fsp } = require('node:fs');", + "import { promises } from 'node:fs';", + "await fs.readFile('/tmp/example', 'utf8');", + "await fsp.writeFile('/tmp/example', payload);", + "await promises.rm('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import { promises as fs } from 'node:fs';", + "const { promises: fsp } = require('node:fs');", + "import { promises } from 'node:fs';", + "await fs.readFile('/tmp/example', 'utf8');", + "await fsp.writeFile('/tmp/example', payload);", + "await promises.rm('/tmp/example');", + ]); + }); + + it("detects combined default and named fs imports", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/index.mjs", + [ + "import nodeFs, { readFile as rf } from 'node:fs';", + "import otherFs, { promises as fsp } from 'node:fs';", + "nodeFs.readFileSync('/tmp/a');", + "await rf('/tmp/b');", + "await fsp.readFile('/tmp/c');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import nodeFs, { readFile as rf } from 'node:fs';", + "import otherFs, { promises as fsp } from 'node:fs';", + "nodeFs.readFileSync('/tmp/a');", + "await rf('/tmp/b');", + "await fsp.readFile('/tmp/c');", + ]); + }); + + it("does not flag local fs-shaped objects as raw Node fs calls", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/virtual.js", + [ + "const fs = makeVirtualFilesystem();", + "const fsPromises = makeVirtualPromises();", + "await fs.readFile('/virtual/file');", + "await fsPromises.writeFile('/virtual/file', value);", + ].join("\n"), + ); + + expect(findings).toHaveLength(0); + }); + + it("detects common destructured fs APIs", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/index.js", + [ + "import { existsSync, copyFile, realpath, readlink, chmod, chown, utimes, watch, mkdtemp, truncate } from 'node:fs';", + "if (existsSync('/tmp/example')) watch('/tmp/example', () => {});", + "await copyFile('/tmp/a', '/tmp/b');", + "await realpath('/tmp/example');", + "await readlink('/tmp/link');", + "await chmod('/tmp/example', 0o600);", + "await chown('/tmp/example', uid, gid);", + "await utimes('/tmp/example', now, now);", + "await mkdtemp('/tmp/plugin-');", + "await truncate('/tmp/example', 0);", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import { existsSync, copyFile, realpath, readlink, chmod, chown, utimes, watch, mkdtemp, truncate } from 'node:fs';", + "if (existsSync('/tmp/example')) watch('/tmp/example', () => {});", + "await copyFile('/tmp/a', '/tmp/b');", + "await realpath('/tmp/example');", + "await readlink('/tmp/link');", + "await chmod('/tmp/example', 0o600);", + "await chown('/tmp/example', uid, gid);", + "await utimes('/tmp/example', now, now);", + "await mkdtemp('/tmp/plugin-');", + "await truncate('/tmp/example', 0);", + ]); + }); + + it("detects destructured fs helpers from promises aliases", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/index.js", + [ + "const fs = require('node:fs');", + "const { readFile, writeFile: wf } = require('node:fs').promises;", + "let { rm } = fs.promises;", + "var { cp } = require('node:fs/promises');", + "await readFile('/tmp/example', 'utf8');", + "await wf('/tmp/example', payload);", + "await rm('/tmp/example');", + "await cp('/tmp/a', '/tmp/b');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "const fs = require('node:fs');", + "const { readFile, writeFile: wf } = require('node:fs').promises;", + "var { cp } = require('node:fs/promises');", + "await readFile('/tmp/example', 'utf8');", + "await wf('/tmp/example', payload);", + "await rm('/tmp/example');", + "await cp('/tmp/a', '/tmp/b');", + ]); + }); + + it("detects destructured fs helpers with default initializers", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/defaulted-destructure.js", + [ + "const { readFile = fallbackRead, writeFile: wf = fallbackWrite } = require('node:fs/promises');", + "await readFile('/tmp/example', 'utf8');", + "await wf('/tmp/example', payload);", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "const { readFile = fallbackRead, writeFile: wf = fallbackWrite } = require('node:fs/promises');", + "await readFile('/tmp/example', 'utf8');", + "await wf('/tmp/example', payload);", + ]); + }); + + it("detects destructured helpers from namespace fs aliases", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/index.js", + [ + "import * as fs from 'node:fs';", + "const { readFileSync, writeFile: wf } = fs;", + "const { copyFileSync } = (fs);", + "const { promises: fsp } = fs;", + "const { promises: { readFile: readPromised, writeFile } } = fs;", + "readFileSync('/tmp/example', 'utf8');", + "copyFileSync('/tmp/a', '/tmp/b');", + "await wf('/tmp/example', payload);", + "await fsp.readFile('/tmp/promised', 'utf8');", + "await readPromised('/tmp/nested', 'utf8');", + "await writeFile('/tmp/nested', payload);", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import * as fs from 'node:fs';", + "readFileSync('/tmp/example', 'utf8');", + "copyFileSync('/tmp/a', '/tmp/b');", + "await wf('/tmp/example', payload);", + "await fsp.readFile('/tmp/promised', 'utf8');", + "await readPromised('/tmp/nested', 'utf8');", + "await writeFile('/tmp/nested', payload);", + ]); + }); + + it("detects destructured raw fs helper aliases after earlier declarators", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/destructured-comma.js", + [ + "import fs from 'node:fs';", + "const noop = 0, { writeFile, readFile: rf } = fs.promises;", + "await writeFile('/tmp/example', payload);", + "await rf('/tmp/example', 'utf8');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "await writeFile('/tmp/example', payload);", + "await rf('/tmp/example', 'utf8');", + ]); + }); + + it("detects calls through raw fs aliases introduced by parameter defaults", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/parameter-default-raw-fs.js", + [ + "function load(", + " fs = require('node:fs'),", + ") {", + " return fs.readFileSync('/tmp/example', 'utf8');", + "}", + "fs.readFile('/virtual/file');", + "function inspect({ fs = require('node:fs') }) {", + " return fs.readFileSync('/tmp/destructured', 'utf8');", + "}", + "const inspectRenamed = ({ local: fs = require('node:fs') }) =>", + " fs.readFileSync('/tmp/renamed', 'utf8');", + "const inspectObject = (", + " fs = require('node:fs'),", + "): { value: string } =>", + " ({", + " value: fs.readFileSync('/tmp/object', 'utf8'),", + " });", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "fs = require('node:fs'),", + "return fs.readFileSync('/tmp/example', 'utf8');", + "function inspect({ fs = require('node:fs') }) {", + "return fs.readFileSync('/tmp/destructured', 'utf8');", + "const inspectRenamed = ({ local: fs = require('node:fs') }) =>", + "fs.readFileSync('/tmp/renamed', 'utf8');", + "fs = require('node:fs'),", + "value: fs.readFileSync('/tmp/object', 'utf8'),", + ]); + }); + + it("detects calls through raw fs helper aliases introduced by destructured parameter defaults", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/parameter-default-raw-fs-helper.js", + [ + "function load({ readFile = require('node:fs/promises').readFile }) {", + " return readFile('/tmp/example', 'utf8');", + "}", + "const write = (", + " { helper = require('node:fs').promises.writeFile },", + ") => helper('/tmp/example', payload);", + "import fs from 'node:fs';", + "function stat({ helper = fs.promises.stat }) {", + " return helper('/tmp/stat');", + "}", + "function local({ readFile = fallbackRead }) {", + " return readFile('/virtual/file');", + "}", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "function load({ readFile = require('node:fs/promises').readFile }) {", + "return readFile('/tmp/example', 'utf8');", + "{ helper = require('node:fs').promises.writeFile },", + ") => helper('/tmp/example', payload);", + "import fs from 'node:fs';", + "function stat({ helper = fs.promises.stat }) {", + "return helper('/tmp/stat');", + ]); + }); + + it("does not flag same-line function parameters that shadow fs aliases", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/shadowed.js", + [ + "import fs from 'node:fs';", + "import { readFile } from 'node:fs/promises';", + "function inspect(fs) { return fs.readFile('/virtual/file'); }", + "function load(readFile) { return readFile('/virtual/file'); }", + "const read = (fs) => fs.readFile('/virtual/file');", + "const write = fs => fs.writeFile('/virtual/file', value);", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "import { readFile } from 'node:fs/promises';", + ]); + }); + + it("detects raw fs usage inside parameter defaults and destructuring that do not bind fs", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/parameter-defaults.js", + [ + "import fs from 'node:fs';", + "function load(path = fs.readFileSync('/tmp/default')) { return path; }", + "function inspect({ fs: local }) { return fs.readFileSync('/tmp/outer'); }", + "function shadow({ local: fs }) { return fs.readFile('/virtual/file'); }", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "function load(path = fs.readFileSync('/tmp/default')) { return path; }", + "function inspect({ fs: local }) { return fs.readFileSync('/tmp/outer'); }", + ]); + }); + + it("detects raw fs calls that appear before a same-line shadow declaration", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/call-before-shadow.js", + [ + "import fs from 'node:fs';", + "fs.readFileSync('/tmp/real'); function inspect(fs) { return fs.readFile('/virtual/file'); }", + "fs.writeFileSync('/tmp/typed-real'); const typed = (fs: VirtualFilesystem): string => fs.readFile('/virtual/typed');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "fs.readFileSync('/tmp/real'); function inspect(fs) { return fs.readFile('/virtual/file'); }", + "fs.writeFileSync('/tmp/typed-real'); const typed = (fs: VirtualFilesystem): string => fs.readFile('/virtual/typed');", + ]); + }); + + it("detects raw fs calls that appear after same-line block-local shadows", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/call-after-block-shadow.js", + [ + "import fs from 'node:fs';", + "import { readFile } from 'node:fs/promises';", + "{ const fs = makeVirtualFilesystem(); fs.readFile('/virtual/inline'); } fs.readFileSync('/tmp/real');", + "if (useVirtual) { const fs = makeVirtualFilesystem(); fs.readFile('/virtual/if'); }", + "fs.writeFileSync('/tmp/after-if', value);", + "{ const { readFile } = makeVirtualFilesystem(); readFile('/virtual/inline'); } await readFile('/tmp/real');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "import { readFile } from 'node:fs/promises';", + "{ const fs = makeVirtualFilesystem(); fs.readFile('/virtual/inline'); } fs.readFileSync('/tmp/real');", + "fs.writeFileSync('/tmp/after-if', value);", + "{ const { readFile } = makeVirtualFilesystem(); readFile('/virtual/inline'); } await readFile('/tmp/real');", + ]); + }); + + it("does not flag block-local declarations that shadow fs aliases", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/shadowed-block.js", + [ + "import fs from 'node:fs';", + "{ const fs = makeVirtualFilesystem(); fs.readFile('/virtual/inline'); }", + "{ const other = 1, localFs = makeVirtualFilesystem(), fs = makeVirtualFilesystem(); fs.readFile('/virtual/comma'); }", + "{ const other = 1; const fs = makeVirtualFilesystem(); fs.readFile('/virtual/semicolon'); }", + "{", + " const fs = makeVirtualFilesystem();", + " fs.readFile('/virtual/file');", + "}", + "fs.readFileSync('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "fs.readFileSync('/tmp/example');", + ]); + }); + + it("does not flag typed destructured parameters that shadow fs aliases", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/shadowed-typed-destructuring.ts", + [ + "import fs from 'node:fs';", + "function inspect({ fs }: { fs: VirtualFilesystem }) { return fs.readFile('/virtual/file'); }", + "const load = ({ fs }: { fs: VirtualFilesystem }) => fs.readFile('/virtual/file');", + "fs.readFileSync('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "fs.readFileSync('/tmp/example');", + ]); + }); + + it("does not flag block-local function declarations that shadow destructured helpers", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/shadowed-function-declaration.js", + [ + "import { readFile } from 'node:fs/promises';", + "{", + " function readFile() { return 'virtual'; }", + " readFile('/virtual/file');", + "}", + "await readFile('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import { readFile } from 'node:fs/promises';", + "await readFile('/tmp/example');", + ]); + }); + + it("does not flag catch bindings that shadow fs aliases", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/catch-shadow.js", + [ + "import fs from 'node:fs';", + "try {", + " runPlugin();", + "} catch (fs) {", + " fs.readFile('/virtual/file');", + "}", + "try { fail(); } catch (fs) { fs.readFile('/virtual/same-line'); } fs.readFileSync('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "try { fail(); } catch (fs) { fs.readFile('/virtual/same-line'); } fs.readFileSync('/tmp/example');", + ]); + }); + + it("does not flag block-local destructured declarations that shadow helpers", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/shadowed-destructuring.js", + [ + "import { readFile } from 'node:fs/promises';", + "{ const other = 1; const { readFile } = makeVirtualFilesystem(); await readFile('/virtual/semicolon'); }", + "{", + " const { readFile } = makeVirtualFilesystem();", + " await readFile('/virtual/file');", + "}", + "await readFile('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import { readFile } from 'node:fs/promises';", + "await readFile('/tmp/example');", + ]); + }); + + it("does not flag function-scoped var shadows after their declaration block closes", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/var-shadow.js", + [ + "import fs from 'node:fs';", + "import { readFile } from 'node:fs/promises';", + "function inspect(useVirtual) {", + " if (useVirtual) {", + " var fs = makeVirtualFilesystem();", + " }", + " return fs.readFile('/virtual/file');", + "}", + "function inspectDestructured(useVirtual) {", + " if (useVirtual) {", + " var { readFile } = makeVirtualFilesystem();", + " }", + " return readFile('/virtual/file');", + "}", + "function sameLine(useVirtual) { if (useVirtual) { var fs = makeVirtualFilesystem(); } return fs.readFile('/virtual/same-line'); } fs.writeFileSync('/tmp/after-same-line');", + "function direct(useVirtual) {", + " var fs = makeVirtualFilesystem();", + " return fs.readFile('/virtual/direct');", + "}", + "fs.rmSync('/tmp/after-direct');", + "fs.readFileSync('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "import { readFile } from 'node:fs/promises';", + "function sameLine(useVirtual) { if (useVirtual) { var fs = makeVirtualFilesystem(); } return fs.readFile('/virtual/same-line'); } fs.writeFileSync('/tmp/after-same-line');", + "fs.rmSync('/tmp/after-direct');", + "fs.readFileSync('/tmp/example');", + ]); + }); + + it("does not flag block-local array destructuring that shadows helpers", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/shadowed-array-destructuring.js", + [ + "import { readFile } from 'node:fs/promises';", + "{", + " const [readFile] = makeVirtualFilesystem();", + " await readFile('/virtual/file');", + "}", + "await readFile('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import { readFile } from 'node:fs/promises';", + "await readFile('/tmp/example');", + ]); + }); + + it("does not treat local fs-shaped destructuring sources as raw fs aliases", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/local-filesystem-destructure.js", + [ + "import { readFile } from 'node:fs/promises';", + "{", + " const filesystem = makeVirtualFilesystem();", + " const { readFile } = filesystem;", + " await readFile('/virtual/file');", + "}", + "await readFile('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import { readFile } from 'node:fs/promises';", + "await readFile('/tmp/example');", + ]); + }); + + it("does not derive fs helper aliases from block-local shadowed namespace aliases", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/shadowed-derived.js", + [ + "import fs from 'node:fs';", + "{", + " const fs = makeVirtualFilesystem();", + " const { readFile } = fs;", + " await readFile('/virtual/file');", + " const fsp = fs.promises;", + " await fsp.writeFile('/virtual/file', value);", + "}", + "fs.readFileSync('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "fs.readFileSync('/tmp/example');", + ]); + }); + + it("does not leak block-local raw fs aliases into later local fs-shaped objects", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/scoped-raw-alias.js", + [ + "{", + " const fs = require('node:fs');", + " fs.readFileSync('/tmp/example');", + "}", + "const fs = makeVirtualFilesystem();", + "fs.readFile('/virtual/file');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "const fs = require('node:fs');", + "fs.readFileSync('/tmp/example');", + ]); + }); + + it("does not flag multiline function parameters that shadow fs aliases", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/shadowed-function.js", + [ + "import fs from 'node:fs';", + "function inspect(fs) {", + " return fs.readFile('/virtual/file');", + "}", + "fs.readFileSync('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "fs.readFileSync('/tmp/example');", + ]); + }); + + it("does not flag multiline arrow parameters that shadow fs aliases", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/shadowed-arrow.js", + [ + "import fs from 'node:fs';", + "const inspect = (", + " fs,", + ") => {", + " return fs.readFile('/virtual/file');", + "};", + "fs.readFileSync('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "fs.readFileSync('/tmp/example');", + ]); + }); + + it("does not flag multiline async arrow parameters that shadow fs aliases", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/shadowed-async-arrow.js", + [ + "import fs from 'node:fs';", + "const inspect = async (", + " fs,", + ") => {", + " return fs.readFile('/virtual/file');", + "};", + "fs.readFileSync('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "fs.readFileSync('/tmp/example');", + ]); + }); + + it("does not flag multiline typed parameters that shadow fs aliases", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/shadowed-typed-params.ts", + [ + "import fs from 'node:fs';", + "const inspect = (", + " fs: VirtualFilesystem,", + "): string => {", + " return fs.readFile('/virtual/file');", + "};", + "function load(", + " fs: VirtualFilesystem,", + "): string {", + " return fs.readFile('/virtual/file');", + "}", + "fs.readFileSync('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "fs.readFileSync('/tmp/example');", + ]); + }); + + it("does not flag same-line method parameters that shadow fs aliases", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/shadowed-method.ts", + [ + "import fs from 'node:fs';", + "fs.readFileSync('/tmp/before'); const plugin = { inspect(fs: VirtualFilesystem) { return fs.readFile('/virtual/file'); } }; fs.writeFileSync('/tmp/after', data);", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "fs.readFileSync('/tmp/before'); const plugin = { inspect(fs: VirtualFilesystem) { return fs.readFile('/virtual/file'); } }; fs.writeFileSync('/tmp/after', data);", + ]); + }); + + it("does not flag multiline class method parameters that shadow fs aliases", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/shadowed-class-method.ts", + [ + "import fs from 'node:fs';", + "class Plugin {", + " inspect(", + " fs: VirtualFilesystem,", + " ) {", + " return fs.readFile('/virtual/file');", + " }", + "}", + "fs.readFileSync('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "fs.readFileSync('/tmp/example');", + ]); + }); + + it("does not flag multiline object method parameters that shadow fs aliases", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/shadowed-object-method.ts", + [ + "import fs from 'node:fs';", + "const plugin = {", + " inspect(", + " fs: VirtualFilesystem,", + " ) {", + " return fs.readFile('/virtual/file');", + " },", + "};", + "fs.readFileSync('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "fs.readFileSync('/tmp/example');", + ]); + }); + + it("does not flag multiline parameter shadow calls on the closing signature line", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/shadowed-arrow-close.js", + [ + "import fs from 'node:fs';", + "const inspect = (", + " fs,", + ") => fs.readFile('/virtual/file');", + "fs.readFileSync('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "fs.readFileSync('/tmp/example');", + ]); + }); + + it("does not flag multiline arrow expression-body parameters that shadow fs aliases", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/shadowed-arrow-expression.ts", + [ + "import fs from 'node:fs';", + "const inspect = (", + " fs,", + ") =>", + " fs.readFile('/virtual/file');", + "const inspectAsync = async (", + " fs: VirtualFilesystem,", + "): Promise =>", + " fs.readFile('/virtual/typed');", + "const inspectTernary = async (", + " fs: VirtualFilesystem,", + "): Promise =>", + " useFirst ?", + " fs.readFile('/virtual/first') :", + " fs.readFile('/virtual/second');", + "const inspectObject = (", + " fs: VirtualFilesystem,", + "): { value: string } =>", + " ({", + " value: fs.readFile('/virtual/object'),", + " });", + "fs.readFileSync('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import fs from 'node:fs';", + "fs.readFileSync('/tmp/example');", + ]); + }); + + it("only flags destructured fs helper calls when they come from fs imports", () => { + const localHelperFindings = scanPackageDryRunFilesystemContent( + "dist/local.js", + [ + "const moduleName = 'fs/promises';", + "async function readFile(path) { return path; }", + "await readFile('/tmp/example');", + ].join("\n"), + ); + const aliasedImportFindings = scanPackageDryRunFilesystemContent( + "dist/imported.js", + ["import { readFile as rf } from 'node:fs/promises';", "await rf('/tmp/example');"].join( + "\n", + ), + ); + + expect(localHelperFindings).toHaveLength(0); + expect(aliasedImportFindings.map((finding) => finding.evidence)).toEqual([ + "import { readFile as rf } from 'node:fs/promises';", + "await rf('/tmp/example');", + ]); + }); + + it("detects sync destructured fs calls and multiline destructured imports", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/index.js", + [ + "import { readFileSync } from 'node:fs';", + "import {", + " writeFile", + "} from 'node:fs/promises';", + "const raw = readFileSync('/tmp/example', 'utf8');", + "await writeFile('/tmp/example', raw);", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "import { readFileSync } from 'node:fs';", + "import {\n writeFile\n} from 'node:fs/promises';", + "const raw = readFileSync('/tmp/example', 'utf8');", + "await writeFile('/tmp/example', raw);", + ]); + }); + + it("detects destructured dynamic fs imports", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/index.js", + [ + "const { readFile } = await import('node:fs/promises');", + "const { writeFile: wf } = await import('node:fs/promises');", + "await readFile('/tmp/example', 'utf8');", + "await wf('/tmp/example', payload);", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "const { readFile } = await import('node:fs/promises');", + "const { writeFile: wf } = await import('node:fs/promises');", + "await readFile('/tmp/example', 'utf8');", + "await wf('/tmp/example', payload);", + ]); + }); + + it("detects nested fs.promises destructuring from raw fs modules", () => { + const findings = scanPackageDryRunFilesystemContent( + "dist/nested-promises.js", + [ + "const { promises: { readFile, writeFile: wf } } = require('node:fs');", + "const { promises: { stat } } = await import('node:fs');", + "await readFile('/tmp/example', 'utf8');", + "await wf('/tmp/example', payload);", + "await stat('/tmp/example');", + ].join("\n"), + ); + + expect(findings.map((finding) => finding.evidence)).toEqual([ + "const { promises: { readFile, writeFile: wf } } = require('node:fs');", + "const { promises: { stat } } = await import('node:fs');", + "await readFile('/tmp/example', 'utf8');", + "await wf('/tmp/example', payload);", + "await stat('/tmp/example');", + ]); + }); + + it("skips oversized files before reading storage content", async () => { + const storage = { + get: async () => { + throw new Error("oversized file should not be read"); + }, + }; + + const result = await runPackageDryRunFilesystemScan({ storage } as never, { + files: [ + { + path: "dist/large.js", + storageId: "storage:large", + size: 257 * 1024, + contentType: "application/javascript", + }, + ], + }); + + expect(result.rawFsUsage.totalCount).toBe(0); + expect(result.fsSafeUsage.totalCount).toBe(0); + }); + + it("skips oversized storage blobs even when file metadata is stale", async () => { + const storage = { + get: async () => ({ + size: 257 * 1024, + text: async () => { + throw new Error("oversized blob should not be read"); + }, + }), + }; + + const result = await runPackageDryRunFilesystemScan({ storage } as never, { + files: [ + { + path: "dist/stale-size.js", + storageId: "storage:stale-size", + size: 12, + contentType: "application/javascript", + }, + ], + }); + + expect(result.rawFsUsage.totalCount).toBe(0); + expect(result.fsSafeUsage.totalCount).toBe(0); + }); + + it("enforces the release byte cap with actual storage blob sizes", async () => { + let textReads = 0; + const storage = { + get: async () => ({ + size: 256 * 1024, + text: async () => { + textReads += 1; + return "import fs from 'node:fs';"; + }, + }), + }; + + const result = await runPackageDryRunFilesystemScan({ storage } as never, { + files: Array.from({ length: 9 }, (_, index) => ({ + path: `dist/file-${index}.js`, + storageId: `storage:file-${index}`, + size: 1, + contentType: "application/javascript", + })), + }); + + expect(textReads).toBe(8); + expect(result.rawFsUsage.totalCount).toBe(8); + }); +}); diff --git a/convex/lib/packageDryRunFilesystemScan.ts b/convex/lib/packageDryRunFilesystemScan.ts new file mode 100644 index 0000000000..974df7cd99 --- /dev/null +++ b/convex/lib/packageDryRunFilesystemScan.ts @@ -0,0 +1,3008 @@ +import type { ActionCtx } from "../_generated/server"; +import { isTextFile } from "./skills"; + +export const PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES = { + RAW_FS_USAGE: "info.filesystem.raw_fs_api_usage", + FS_SAFE_USAGE: "info.filesystem.fs_safe_usage", +} as const; + +type PackageDryRunFilesystemReasonCode = + (typeof PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES)[keyof typeof PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES]; + +export type PackageDryRunFilesystemFindingLike = { + code: string; + severity: string; + file: string; + line: number; + message: string; + evidence: string; +}; + +export type PackageDryRunFilesystemEvidenceItem = { + code: PackageDryRunFilesystemReasonCode; + severity: string; + file: string; + line: number; + message: string; + evidence: string; + evidenceTruncated: boolean; +}; + +export type PackageDryRunFilesystemEvidenceBucket = { + reasonCode: PackageDryRunFilesystemReasonCode; + totalCount: number; + returnedCount: number; + omittedCount: number; + truncatedEvidenceCount: number; + evidence: PackageDryRunFilesystemEvidenceItem[]; +}; + +export type PackageDryRunFilesystemEvidence = { + rawFsUsage: PackageDryRunFilesystemEvidenceBucket; + fsSafeUsage: PackageDryRunFilesystemEvidenceBucket; +}; + +type BuildPackageDryRunFilesystemEvidenceOptions = { + maxEvidenceItems?: number; + maxEvidenceChars?: number; +}; + +type PackageDryRunFilesystemScanInput = { + files: Array<{ + path: string; + storageId: string; + size?: number; + contentType?: string; + }>; +}; + +type PackageDryRunFilesystemEvidenceAccumulator = { + rawFsUsage: PackageDryRunFilesystemBucketAccumulator; + fsSafeUsage: PackageDryRunFilesystemBucketAccumulator; +}; + +type PackageDryRunFilesystemBucketAccumulator = { + reasonCode: PackageDryRunFilesystemReasonCode; + totalCount: number; + findings: PackageDryRunFilesystemFindingLike[]; +}; + +const DEFAULT_MAX_EVIDENCE_ITEMS = 5; +const DEFAULT_MAX_EVIDENCE_CHARS = 160; +const MAX_SCAN_FILE_BYTES = 256 * 1024; +const MAX_SCAN_RELEASE_BYTES = 2 * 1024 * 1024; +const ELLIPSIS = "..."; +const RAW_FS_METHODS = [ + "access", + "accessSync", + "appendFile", + "appendFileSync", + "chmod", + "chmodSync", + "chown", + "chownSync", + "close", + "closeSync", + "copyFile", + "copyFileSync", + "cp", + "cpSync", + "createReadStream", + "createWriteStream", + "exists", + "existsSync", + "fchmod", + "fchmodSync", + "fchown", + "fchownSync", + "fdatasync", + "fdatasyncSync", + "fstat", + "fstatSync", + "fsync", + "fsyncSync", + "ftruncate", + "ftruncateSync", + "futimes", + "futimesSync", + "lchmod", + "lchmodSync", + "lchown", + "lchownSync", + "link", + "linkSync", + "lstat", + "lstatSync", + "lutimes", + "lutimesSync", + "mkdir", + "mkdirSync", + "mkdtemp", + "mkdtempSync", + "open", + "openSync", + "opendir", + "opendirSync", + "read", + "readSync", + "readdir", + "readdirSync", + "readFile", + "readFileSync", + "readlink", + "readlinkSync", + "readv", + "readvSync", + "realpath", + "realpathSync", + "rename", + "renameSync", + "rm", + "rmSync", + "rmdir", + "rmdirSync", + "stat", + "statSync", + "statfs", + "statfsSync", + "symlink", + "symlinkSync", + "truncate", + "truncateSync", + "unlink", + "unlinkSync", + "unwatchFile", + "utimes", + "utimesSync", + "watch", + "watchFile", + "write", + "writeSync", + "writeFile", + "writeFileSync", + "writev", + "writevSync", +] as const; +const RAW_FS_METHOD_PATTERN = RAW_FS_METHODS.map((name) => escapeRegExp(name)) + .sort((left, right) => right.length - left.length) + .join("|"); +const OPTIONAL_MEMBER_ACCESS_PATTERN = String.raw`\s*(?:\?\.|\.)\s*`; +const RAW_FS_MODULE_PATTERN = + /\b(?:import\s+(?!type\b)(?:(?:[^;"']+)\s+from\s*)?["'](?:node:)?fs(?:\/promises)?["']|export\s+(?!type\b)(?:(?:\*\s+as\s+[A-Za-z_$][\w$]*|\*|\{[^}]+\})\s+from\s*)["'](?:node:)?fs(?:\/promises)?["']|import\s+[A-Za-z_$][\w$]*\s*=\s*require\(\s*["'](?:node:)?fs(?:\/promises)?["']\s*\)|require\(\s*["'](?:node:)?fs(?:\/promises)?["']\s*\)|import\(\s*["'](?:node:)?fs(?:\/promises)?["']\s*\))/; +const RAW_FS_TYPE_ONLY_IMPORT_PATTERN = + /\bimport\s+type\s+(?:(?:[^;"']+)\s+from\s*["'](?:node:)?fs(?:\/promises)?["']|[A-Za-z_$][\w$]*\s*=\s*require\(\s*["'](?:node:)?fs(?:\/promises)?["']\s*\))/; +const RAW_FS_NAMED_IMPORT_PATTERN = + /import\s*\{([^}]+)\}\s*from\s*["'](?:node:)?fs(?:\/promises)?["']/; +const RAW_FS_NAMED_EXPORT_PATTERN = + /export\s*\{([^}]+)\}\s*from\s*["'](?:node:)?fs(?:\/promises)?["']/; +const RAW_FS_NAMESPACE_PATTERN = + /(?:import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s*["'](?:node:)?fs(?:\/promises)?["']|import\s+([A-Za-z_$][\w$]*)\s+from\s*["'](?:node:)?fs(?:\/promises)?["']|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*["'](?:node:)?fs(?:\/promises)?["']\s*\)|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*await\s+import\(\s*["'](?:node:)?fs(?:\/promises)?["']\s*\)|import\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*["'](?:node:)?fs(?:\/promises)?["']\s*\))/; +const RAW_FS_DEFAULT_NAMESPACE_ALIAS_PATTERN = + /(^|[^.\w$])([A-Za-z_$][\w$]*)\s*=\s*(?:require\(\s*["'](?:node:)?fs(?:\/promises)?["']\s*\)|await\s+import\(\s*["'](?:node:)?fs(?:\/promises)?["']\s*\))/; +const RAW_FS_COMBINED_NAMESPACE_IMPORT_PATTERN = + /import\s+([A-Za-z_$][\w$]*)\s*,\s*\{[^}]*\}\s*from\s*["'](?:node:)?fs(?:\/promises)?["']/; +const RAW_FS_DEFAULT_ALIAS_PATTERN = + /import\s*\{[^}]*\bdefault\s+as\s+([A-Za-z_$][\w$]*)[^}]*\}\s*from\s*["'](?:node:)?fs(?:\/promises)?["']/; +const RAW_FS_PROMISES_ALIAS_PATTERN = + /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*["'](?:node:)?fs["']\s*\)\s*(?:\?\.|\.)\s*promises\b/; +const RAW_FS_NAMESPACE_PROMISES_ALIAS_PATTERN = + /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([A-Za-z_$][\w$]*)\s*(?:\?\.|\.)\s*promises\b/; +const RAW_FS_DESTRUCTURED_IMPORT_PATTERN = + /(?:import\s*\{([^}]+)\}\s*from\s*["'](?:node:)?fs(?:\/promises)?["']|(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\(\s*["'](?:node:)?fs(?:\/promises)?["']\s*\)|(?:const|let|var)\s*\{([^}]+)\}\s*=\s*await\s+import\(\s*["'](?:node:)?fs(?:\/promises)?["']\s*\))/; +const RAW_FS_COMBINED_DESTRUCTURED_IMPORT_PATTERN = + /import\s+[A-Za-z_$][\w$]*\s*,\s*\{([^}]+)\}\s*from\s*["'](?:node:)?fs(?:\/promises)?["']/; +const RAW_FS_PROMISES_DESTRUCTURED_IMPORT_PATTERN = + /(?:const|let|var)\s*\{([^}]+)\}\s*=\s*require\(\s*["'](?:node:)?fs["']\s*\)\s*(?:\?\.|\.)\s*promises\b/; +const RAW_FS_NAMESPACE_PROMISES_DESTRUCTURED_PATTERN = + /(?:const|let|var)\s*\{([^}]+)\}\s*=\s*([A-Za-z_$][\w$]*)\s*(?:\?\.|\.)\s*promises\b/; +const RAW_FS_NAMESPACE_DESTRUCTURED_PATTERN = + /(?:const|let|var)\s*\{((?:[^{}]|\{[^{}]*\})+)\}\s*=\s*\(?\s*([A-Za-z_$][\w$]*)\s*\)?\b/; +const RAW_FS_PROMISES_ALIAS_DECLARATOR_PATTERN = + /^([A-Za-z_$][\w$]*)\s*=\s*require\(\s*["'](?:node:)?fs["']\s*\)\s*(?:\?\.|\.)\s*promises\b/; +const RAW_FS_NAMESPACE_PROMISES_ALIAS_DECLARATOR_PATTERN = + /^([A-Za-z_$][\w$]*)\s*=\s*([A-Za-z_$][\w$]*)\s*(?:\?\.|\.)\s*promises\b/; +const RAW_FS_NAMESPACE_MEMBER_ALIAS_PATTERN = new RegExp( + String.raw`(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([A-Za-z_$][\w$]*)${OPTIONAL_MEMBER_ACCESS_PATTERN}(?:(?:promises)${OPTIONAL_MEMBER_ACCESS_PATTERN})?(?:${RAW_FS_METHOD_PATTERN})\b`, +); +const RAW_FS_NAMESPACE_MEMBER_ALIAS_DECLARATOR_PATTERN = new RegExp( + String.raw`^([A-Za-z_$][\w$]*)\s*=\s*([A-Za-z_$][\w$]*)${OPTIONAL_MEMBER_ACCESS_PATTERN}(?:(?:promises)${OPTIONAL_MEMBER_ACCESS_PATTERN})?(?:${RAW_FS_METHOD_PATTERN})\b`, +); +const RAW_FS_MODULE_MEMBER_ALIAS_PATTERN = new RegExp( + String.raw`(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:require\(\s*["'](?:node:)?fs(?:\/promises)?["']\s*\)|await\s+import\(\s*["'](?:node:)?fs(?:\/promises)?["']\s*\))${OPTIONAL_MEMBER_ACCESS_PATTERN}(?:(?:promises)${OPTIONAL_MEMBER_ACCESS_PATTERN})?(?:${RAW_FS_METHOD_PATTERN})\b`, +); +const RAW_FS_MODULE_MEMBER_ALIAS_DECLARATOR_PATTERN = new RegExp( + String.raw`^([A-Za-z_$][\w$]*)\s*=\s*(?:require\(\s*["'](?:node:)?fs(?:\/promises)?["']\s*\)|await\s+import\(\s*["'](?:node:)?fs(?:\/promises)?["']\s*\))${OPTIONAL_MEMBER_ACCESS_PATTERN}(?:(?:promises)${OPTIONAL_MEMBER_ACCESS_PATTERN})?(?:${RAW_FS_METHOD_PATTERN})\b`, +); +const RAW_FS_DESTRUCTURED_NAMES = new Set(RAW_FS_METHODS); +const FS_SAFE_MODULE_SPECIFIER_PATTERN = String.raw`(?:@openclaw\/fs-safe|openclaw\/plugin-sdk\/(?:security-runtime|file-access-runtime))`; +const FS_SAFE_HELPERS = [ + "openFileWithinRoot", + "readFileWithinRoot", + "writeFileWithinRoot", + "writeFileFromPathWithinRoot", + "writeExternalFileWithinRoot", + "sanitizeUntrustedFileName", + "writeViaSiblingTempPath", +] as const; +const FS_SAFE_HELPER_PATTERN = FS_SAFE_HELPERS.map((name) => escapeRegExp(name)) + .sort((left, right) => right.length - left.length) + .join("|"); +const FS_SAFE_HELPER_NAMES = new Set(FS_SAFE_HELPERS); +const FS_SAFE_MODULE_PATTERN = new RegExp( + String.raw`\b(?:import\s+(?!type\b)(?:(?:[^;"']+)\s+from\s*)?["']${FS_SAFE_MODULE_SPECIFIER_PATTERN}["']|export\s+(?!type\b)(?:(?:\*\s+as\s+[A-Za-z_$][\w$]*|\*|\{[^}]+\})\s+from\s*)["']${FS_SAFE_MODULE_SPECIFIER_PATTERN}["']|require\(\s*["']${FS_SAFE_MODULE_SPECIFIER_PATTERN}["']\s*\)|import\(\s*["']${FS_SAFE_MODULE_SPECIFIER_PATTERN}["']\s*\))`, +); +const FS_SAFE_TYPE_ONLY_IMPORT_PATTERN = new RegExp( + String.raw`\bimport\s+type\s+(?:(?:[^;"']+)\s+from\s*["']${FS_SAFE_MODULE_SPECIFIER_PATTERN}["']|[A-Za-z_$][\w$]*\s*=\s*require\(\s*["']${FS_SAFE_MODULE_SPECIFIER_PATTERN}["']\s*\))`, +); +const FS_SAFE_NAMED_IMPORT_PATTERN = new RegExp( + String.raw`import\s*\{([^}]+)\}\s*from\s*["']${FS_SAFE_MODULE_SPECIFIER_PATTERN}["']`, +); +const FS_SAFE_NAMED_EXPORT_PATTERN = new RegExp( + String.raw`export\s*\{([^}]+)\}\s*from\s*["']${FS_SAFE_MODULE_SPECIFIER_PATTERN}["']`, +); +const FS_SAFE_COMBINED_NAMED_IMPORT_PATTERN = new RegExp( + String.raw`import\s+[A-Za-z_$][\w$]*\s*,\s*\{([^}]+)\}\s*from\s*["']${FS_SAFE_MODULE_SPECIFIER_PATTERN}["']`, +); +const FS_SAFE_NAMESPACE_PATTERN = new RegExp( + String.raw`(?:import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s*["']${FS_SAFE_MODULE_SPECIFIER_PATTERN}["']|(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:require\(\s*["']${FS_SAFE_MODULE_SPECIFIER_PATTERN}["']\s*\)|await\s+import\(\s*["']${FS_SAFE_MODULE_SPECIFIER_PATTERN}["']\s*\)))`, +); +const FS_SAFE_NAMESPACE_DECLARATOR_PATTERN = new RegExp( + String.raw`^([A-Za-z_$][\w$]*)\s*=\s*(?:require\(\s*["']${FS_SAFE_MODULE_SPECIFIER_PATTERN}["']\s*\)|await\s+import\(\s*["']${FS_SAFE_MODULE_SPECIFIER_PATTERN}["']\s*\))`, +); +const FS_SAFE_DESTRUCTURED_IMPORT_PATTERN = new RegExp( + String.raw`(?:import\s*\{([^}]+)\}\s*from\s*["']${FS_SAFE_MODULE_SPECIFIER_PATTERN}["']|(?:const|let|var)\s*\{([^}]+)\}\s*=\s*(?:require\(\s*["']${FS_SAFE_MODULE_SPECIFIER_PATTERN}["']\s*\)|await\s+import\(\s*["']${FS_SAFE_MODULE_SPECIFIER_PATTERN}["']\s*\)))`, +); +const FS_SAFE_NAMESPACE_DESTRUCTURED_PATTERN = + /(?:const|let|var)\s*\{((?:[^{}]|\{[^{}]*\})+)\}\s*=\s*\(?\s*([A-Za-z_$][\w$]*)\s*\)?\b/; +const FS_SAFE_NAMESPACE_MEMBER_ALIAS_PATTERN = new RegExp( + String.raw`(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([A-Za-z_$][\w$]*)${OPTIONAL_MEMBER_ACCESS_PATTERN}(?:${FS_SAFE_HELPER_PATTERN})\b`, +); +const FS_SAFE_NAMESPACE_MEMBER_ALIAS_DECLARATOR_PATTERN = new RegExp( + String.raw`^([A-Za-z_$][\w$]*)\s*=\s*([A-Za-z_$][\w$]*)${OPTIONAL_MEMBER_ACCESS_PATTERN}(?:${FS_SAFE_HELPER_PATTERN})\b`, +); + +export async function runPackageDryRunFilesystemScan( + ctx: Pick, + input: PackageDryRunFilesystemScanInput, +): Promise { + const accumulator = createPackageDryRunFilesystemEvidenceAccumulator(); + let scannedBytes = 0; + for (const file of input.files) { + if (!isTextFile(file.path, file.contentType ?? undefined)) continue; + const fileSize = file.size; + if (!isScanFileSizeAllowed(fileSize)) continue; + const remainingBytes = MAX_SCAN_RELEASE_BYTES - scannedBytes; + if (remainingBytes <= 0) break; + const read = await readStorageTextWithinLimit(ctx, file.storageId, remainingBytes); + if (read === null) continue; + scannedBytes += read.size; + scanPackageDryRunFilesystemContentWithRecorder(file.path, read.content, (finding) => + recordPackageDryRunFilesystemFinding(accumulator, finding, DEFAULT_MAX_EVIDENCE_ITEMS), + ); + } + return buildPackageDryRunFilesystemEvidenceFromAccumulator(accumulator); +} + +async function readStorageTextWithinLimit( + ctx: Pick, + storageId: string, + remainingBytes: number, +) { + const blob = await ctx.storage.get(storageId as never); + if (!blob) throw new Error("Uploaded file no longer exists"); + if (!isScanFileSizeAllowed(blob.size)) return null; + if (blob.size > remainingBytes) return null; + return { content: await blob.text(), size: blob.size }; +} + +function isScanFileSizeAllowed(size: number | undefined): size is number { + return ( + typeof size === "number" && Number.isInteger(size) && size >= 0 && size <= MAX_SCAN_FILE_BYTES + ); +} + +export function scanPackageDryRunFilesystemContent( + path: string, + content: string, +): PackageDryRunFilesystemFindingLike[] { + const findings: PackageDryRunFilesystemFindingLike[] = []; + scanPackageDryRunFilesystemContentWithRecorder(path, content, (finding) => + findings.push(finding), + ); + return findings; +} + +function scanPackageDryRunFilesystemContentWithRecorder( + path: string, + content: string, + recordFinding: (finding: PackageDryRunFilesystemFindingLike) => void, +) { + const evidenceLines = content.split(/\r?\n/); + const sanitizedContent = stripTypeOnlyFsSafeImports( + stripTypeOnlyFsImports(sanitizeFilesystemScanCode(content)), + ); + const scanLines = sanitizedContent.split(/\r?\n/); + const rawFsModuleUsageEvidenceByLine = collectRawFsModuleUsageEvidenceByLine( + sanitizedContent, + evidenceLines, + ); + const fsSafeModuleUsageEvidenceByLine = collectFsSafeModuleUsageEvidenceByLine( + sanitizedContent, + evidenceLines, + ); + const namespaceFsNamesByLine = collectNamespaceFsNamesByLine(scanLines); + const namespaceFsNames = collectNamesFromLines(namespaceFsNamesByLine); + const destructuredFsNamesByLine = collectDestructuredFsNamesByLine( + scanLines, + namespaceFsNamesByLine, + ); + const destructuredFsNames = collectNamesFromLines(destructuredFsNamesByLine); + const fsSafeNamespaceNamesByLine = collectFsSafeNamespaceNamesByLine(scanLines); + const fsSafeHelperNamesByLine = collectFsSafeHelperNamesByLine( + scanLines, + fsSafeNamespaceNamesByLine, + ); + const fsSafeNamespaceNames = collectNamesFromLines(fsSafeNamespaceNamesByLine); + const fsSafeHelperNames = collectNamesFromLines(fsSafeHelperNamesByLine); + const namespaceFsDeclarationsByLine = collectNewlyActiveNamesByLine(namespaceFsNamesByLine); + const destructuredFsDeclarationsByLine = collectNewlyActiveNamesByLine(destructuredFsNamesByLine); + const fsSafeNamespaceDeclarationsByLine = collectNewlyActiveNamesByLine( + fsSafeNamespaceNamesByLine, + ); + const fsSafeHelperDeclarationsByLine = collectNewlyActiveNamesByLine(fsSafeHelperNamesByLine); + const namespaceShadowsByLine = collectShadowedNamesByLine( + scanLines, + namespaceFsNames, + namespaceFsDeclarationsByLine, + ); + const availableNamespaceFsNamesByLine = subtractShadowedNamesByLine( + namespaceFsNamesByLine, + namespaceShadowsByLine, + ); + const destructuredShadowsByLine = collectShadowedNamesByLine( + scanLines, + destructuredFsNames, + destructuredFsDeclarationsByLine, + availableNamespaceFsNamesByLine, + ); + const fsSafeNamespaceShadowsByLine = collectShadowedNamesByLine( + scanLines, + fsSafeNamespaceNames, + fsSafeNamespaceDeclarationsByLine, + ); + const fsSafeHelperShadowsByLine = collectShadowedNamesByLine( + scanLines, + fsSafeHelperNames, + fsSafeHelperDeclarationsByLine, + fsSafeNamespaceNamesByLine, + ); + for (let index = 0; index < scanLines.length; index += 1) { + const line = scanLines[index] ?? ""; + const rawFsModuleUsageEvidence = rawFsModuleUsageEvidenceByLine.get(index); + const evidence = rawFsModuleUsageEvidence ?? (evidenceLines[index] ?? "").trim(); + if ( + rawFsModuleUsageEvidence !== undefined || + hasNamespaceFsCall( + line, + namespaceFsNamesByLine[index] ?? new Set(), + namespaceShadowsByLine[index] ?? new Set(), + ) + ) { + recordFinding({ + code: PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.RAW_FS_USAGE, + severity: "info", + file: path, + line: index + 1, + message: "Raw Node filesystem API usage detected.", + evidence, + }); + } else if ( + hasDestructuredFsCall( + line, + destructuredFsNamesByLine[index] ?? new Set(), + destructuredShadowsByLine[index] ?? new Set(), + ) + ) { + recordFinding({ + code: PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.RAW_FS_USAGE, + severity: "info", + file: path, + line: index + 1, + message: "Raw filesystem helper usage detected.", + evidence, + }); + } + const fsSafeModuleUsageEvidence = fsSafeModuleUsageEvidenceByLine.get(index); + const fsSafeEvidence = fsSafeModuleUsageEvidence ?? (evidenceLines[index] ?? "").trim(); + if ( + fsSafeModuleUsageEvidence !== undefined || + hasNamespaceFsSafeCall( + line, + fsSafeNamespaceNamesByLine[index] ?? new Set(), + fsSafeNamespaceShadowsByLine[index] ?? new Set(), + ) || + hasDestructuredFsCall( + line, + fsSafeHelperNamesByLine[index] ?? new Set(), + fsSafeHelperShadowsByLine[index] ?? new Set(), + ) + ) { + recordFinding({ + code: PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.FS_SAFE_USAGE, + severity: "info", + file: path, + line: index + 1, + message: "OpenClaw filesystem safety helper usage detected.", + evidence: fsSafeEvidence, + }); + } + } +} + +function sanitizeFilesystemScanCode(content: string) { + let output = ""; + let index = 0; + let inBlockComment = false; + + while (index < content.length) { + const character = content[index] ?? ""; + const next = content[index + 1] ?? ""; + + if (inBlockComment) { + if (character === "*" && next === "/") { + output += " "; + index += 2; + inBlockComment = false; + } else { + output += character === "\n" || character === "\r" ? character : " "; + index += 1; + } + continue; + } + + if (character === "/" && next === "*") { + output += " "; + index += 2; + inBlockComment = true; + continue; + } + + if (character === "/" && next === "/") { + output += " "; + index += 2; + while (index < content.length && content[index] !== "\n" && content[index] !== "\r") { + output += " "; + index += 1; + } + continue; + } + + if (character === "/" && isLikelyRegexLiteralStart(output)) { + const result = sanitizeRegexLiteral(content, index); + if (result) { + output += result.value; + index = result.nextIndex; + continue; + } + } + + if (character === "'" || character === '"' || character === "`") { + const result = sanitizeStringLiteral(content, index, output); + output += result.value; + index = result.nextIndex; + continue; + } + + output += character; + index += 1; + } + + return output; +} + +function sanitizeRegexLiteral(content: string, startIndex: number) { + let value = "/"; + let index = startIndex + 1; + let inCharacterClass = false; + + while (index < content.length) { + const character = content[index] ?? ""; + const next = content[index + 1] ?? ""; + if (character === "\n" || character === "\r") return null; + if (character === "\\") { + value += " "; + if (index + 1 < content.length) { + value += next === "\n" || next === "\r" ? next : " "; + index += 2; + } else { + index += 1; + } + continue; + } + if (character === "[") inCharacterClass = true; + if (character === "]") inCharacterClass = false; + if (character === "/" && !inCharacterClass) { + value += "/"; + index += 1; + while (/[A-Za-z]/.test(content[index] ?? "")) { + value += " "; + index += 1; + } + return { value, nextIndex: index }; + } + value += " "; + index += 1; + } + + return null; +} + +function isLikelyRegexLiteralStart(outputBeforeSlash: string) { + let index = outputBeforeSlash.length - 1; + while (index >= 0 && /\s/.test(outputBeforeSlash[index] ?? "")) index -= 1; + if (index < 0) return true; + const previous = outputBeforeSlash[index] ?? ""; + const prefix = outputBeforeSlash.slice(0, index + 1); + if (/(^|[^\w$])(?:return|throw|yield|case|delete|void|typeof|instanceof|in)\s*$/.test(prefix)) { + return true; + } + if (/(^|[;{}])\s*(?:if|while|for|with)\s*\([^)]*\)\s*$/.test(prefix)) { + return true; + } + return "([{=,:;!&|?+-*~^<>".includes(previous); +} + +function sanitizeStringLiteral(content: string, startIndex: number, outputBeforeLiteral: string) { + const quote = content[startIndex] ?? ""; + const preserveLiteral = shouldPreserveModuleSpecifierLiteral(outputBeforeLiteral); + let value = quote; + let index = startIndex + 1; + + while (index < content.length) { + const character = content[index] ?? ""; + const next = content[index + 1] ?? ""; + if (character === "\\") { + value += preserveLiteral ? character : " "; + if (index + 1 < content.length) { + value += next === "\n" || next === "\r" ? next : preserveLiteral ? next : " "; + index += 2; + } else { + index += 1; + } + continue; + } + + if (!preserveLiteral && quote === "`" && character === "$" && next === "{") { + const closeIndex = findMatchingBrace(content, index + 1); + if (closeIndex !== null) { + value += ` ${sanitizeFilesystemScanCode(content.slice(index + 2, closeIndex))} `; + index = closeIndex + 1; + continue; + } + } + + if (character === quote) { + value += quote; + index += 1; + break; + } + + value += + character === "\n" || character === "\r" ? character : preserveLiteral ? character : " "; + index += 1; + } + + return { value, nextIndex: index }; +} + +function shouldPreserveModuleSpecifierLiteral(outputBeforeLiteral: string) { + const linePrefix = outputBeforeLiteral.slice(outputBeforeLiteral.lastIndexOf("\n") + 1); + return ( + /\bimport\s+(?!\()[^;"']*\bfrom\s*$/.test(linePrefix) || + /(?:^|[^\w$])from\s*$/.test(linePrefix) || + /\brequire\s*\(\s*$/.test(linePrefix) || + /\bimport\s*\(\s*$/.test(linePrefix) + ); +} + +function stripTypeOnlyFsImports(line: string) { + return line + .replace(new RegExp(RAW_FS_TYPE_ONLY_IMPORT_PATTERN.source, "g"), (match: string) => + blankNonLineBreaks(match), + ) + .replace( + new RegExp(RAW_FS_NAMED_IMPORT_PATTERN.source, "g"), + (match: string, specifiers: string) => { + const typeOnly = specifiers + .split(",") + .map((specifier) => specifier.trim()) + .filter(Boolean) + .every((specifier) => specifier.startsWith("type ")); + return typeOnly ? blankNonLineBreaks(match) : match; + }, + ) + .replace( + new RegExp(RAW_FS_NAMED_EXPORT_PATTERN.source, "g"), + (match: string, specifiers: string) => { + const typeOnly = specifiers + .split(",") + .map((specifier) => specifier.trim()) + .filter(Boolean) + .every((specifier) => specifier.startsWith("type ")); + return typeOnly ? blankNonLineBreaks(match) : match; + }, + ); +} + +function stripTypeOnlyFsSafeImports(line: string) { + return line + .replace(new RegExp(FS_SAFE_TYPE_ONLY_IMPORT_PATTERN.source, "g"), (match: string) => + blankNonLineBreaks(match), + ) + .replace(new RegExp(FS_SAFE_NAMED_IMPORT_PATTERN.source, "g"), blankTypeOnlySpecifiers) + .replace(new RegExp(FS_SAFE_NAMED_EXPORT_PATTERN.source, "g"), blankTypeOnlySpecifiers); +} + +function blankTypeOnlySpecifiers(match: string, specifiers: string) { + const typeOnly = specifiers + .split(",") + .map((specifier) => specifier.trim()) + .filter(Boolean) + .every((specifier) => specifier.startsWith("type ")); + return typeOnly ? blankNonLineBreaks(match) : match; +} + +function blankNonLineBreaks(value: string) { + return value.replace(/[^\r\n]/g, " "); +} + +function collectRawFsModuleUsageEvidenceByLine(content: string, evidenceLines: readonly string[]) { + const evidenceByLine = new Map(); + for (const match of content.matchAll(new RegExp(RAW_FS_MODULE_PATTERN.source, "g"))) { + if (match.index === undefined) continue; + const startLine = lineIndexForContentOffset(content, match.index); + if (evidenceByLine.has(startLine)) continue; + const endLine = lineIndexForContentOffset(content, match.index + match[0].length); + evidenceByLine.set(startLine, evidenceForLineRange(evidenceLines, startLine, endLine)); + } + return evidenceByLine; +} + +function collectFsSafeModuleUsageEvidenceByLine(content: string, evidenceLines: readonly string[]) { + const evidenceByLine = new Map(); + for (const match of content.matchAll(new RegExp(FS_SAFE_MODULE_PATTERN.source, "g"))) { + if (match.index === undefined) continue; + const startLine = lineIndexForContentOffset(content, match.index); + if (evidenceByLine.has(startLine)) continue; + const endLine = lineIndexForContentOffset(content, match.index + match[0].length); + evidenceByLine.set(startLine, evidenceForLineRange(evidenceLines, startLine, endLine)); + } + return evidenceByLine; +} + +function evidenceForLineRange(lines: readonly string[], startLine: number, endLine: number) { + return lines + .slice(startLine, endLine + 1) + .join("\n") + .trim(); +} + +type ScopedFsName = { + name: string; + line: number; + endLineExclusive: number; +}; + +type ParameterSpan = { + openIndex: number; + closeIndex: number; +}; + +type VariableDeclarator = { + text: string; + startIndex: number; + statementStartIndex: number; + declaration: string; +}; + +type ObjectBindingVariableDeclarator = VariableDeclarator & { + specifiers: string; + source: string; +}; + +function collectNamespaceFsNamesByLine(lines: readonly string[]) { + const declarations: ScopedFsName[] = []; + const content = lines.join("\n"); + const variableDeclarators = findVariableDeclarators(content); + for (const match of content.matchAll(new RegExp(RAW_FS_NAMESPACE_PATTERN, "g"))) { + const name = match?.[1] ?? match?.[2] ?? match?.[3] ?? match?.[4] ?? match?.[5]; + if (name) declarations.push(createScopedFsName(lines, content, match, name)); + } + for (const match of content.matchAll(new RegExp(RAW_FS_DEFAULT_NAMESPACE_ALIAS_PATTERN, "g"))) { + const name = match?.[2]; + if (name) declarations.push(createScopedFsName(lines, content, match, name)); + } + for (const match of content.matchAll(new RegExp(RAW_FS_COMBINED_NAMESPACE_IMPORT_PATTERN, "g"))) { + const name = match?.[1]; + if (name) declarations.push(createScopedFsName(lines, content, match, name)); + } + for (const match of content.matchAll(new RegExp(RAW_FS_DEFAULT_ALIAS_PATTERN, "g"))) { + const name = match?.[1]; + if (name) declarations.push(createScopedFsName(lines, content, match, name)); + } + for (const match of content.matchAll(new RegExp(RAW_FS_PROMISES_ALIAS_PATTERN, "g"))) { + const name = match?.[1]; + if (name) declarations.push(createScopedFsName(lines, content, match, name)); + } + for (const declarator of variableDeclarators) { + const match = RAW_FS_PROMISES_ALIAS_DECLARATOR_PATTERN.exec(declarator.text); + const name = match?.[1]; + if (name) + declarations.push(createScopedFsNameFromVariableDeclarator(lines, content, declarator, name)); + } + for (const match of content.matchAll(new RegExp(RAW_FS_DESTRUCTURED_IMPORT_PATTERN, "g"))) { + const specifiers = match?.[1] ?? match?.[2] ?? match?.[3]; + if (!specifiers) continue; + for (const specifier of splitTopLevel(specifiers, ",")) { + const name = parseDestructuredFsPromisesName(specifier); + if (name) declarations.push(createScopedFsName(lines, content, match, name)); + } + } + for (const match of content.matchAll( + new RegExp(RAW_FS_COMBINED_DESTRUCTURED_IMPORT_PATTERN, "g"), + )) { + const specifiers = match?.[1]; + if (!specifiers) continue; + for (const specifier of splitTopLevel(specifiers, ",")) { + const name = parseDestructuredFsPromisesName(specifier); + if (name) declarations.push(createScopedFsName(lines, content, match, name)); + } + } + for (const declaration of findDestructuredFsRuntimeDeclarations(content)) { + for (const specifier of splitTopLevel(declaration.specifiers, ",")) { + const name = parseDestructuredFsPromisesName(specifier); + if (name) { + declarations.push( + createScopedFsNameFromSource( + lines, + content, + declaration.matchIndex, + declaration.declaration, + name, + ), + ); + } + } + } + let addedAlias = true; + while (addedAlias) { + addedAlias = false; + const namesByLine = buildScopedNamesByLine(lines, declarations); + const declarationsByLine = collectNewlyActiveNamesByLine(namesByLine); + const names = collectNamesFromLines(namesByLine); + const shadowedByLine = collectShadowedNamesByLine(lines, names, declarationsByLine); + for (const match of content.matchAll(new RegExp(RAW_FS_NAMESPACE_DESTRUCTURED_PATTERN, "g"))) { + const specifiers = match?.[1]; + const source = match?.[2]; + if ( + !specifiers || + !source || + !isNamespaceSourceAvailable(content, match, source, namesByLine, shadowedByLine) + ) + continue; + for (const specifier of splitTopLevel(specifiers, ",")) { + const name = parseDestructuredFsPromisesName(specifier); + if (name && !hasScopedNameDeclaration(declarations, name, content, match)) { + declarations.push(createScopedFsName(lines, content, match, name)); + addedAlias = true; + } + } + } + for (const declarator of variableDeclarators) { + const objectBinding = parseObjectBindingVariableDeclarator(declarator); + if (!objectBinding) continue; + const match = /^\s*=\s*\(?\s*([A-Za-z_$][\w$]*)\s*\)?\b/.exec(objectBinding.source); + const source = match?.[1]; + if ( + !source || + !isNamespaceSourceAvailableAt( + content, + objectBinding.startIndex, + source, + namesByLine, + shadowedByLine, + ) + ) + continue; + for (const specifier of splitTopLevel(objectBinding.specifiers, ",")) { + const name = parseDestructuredFsPromisesName(specifier); + if ( + name && + !hasScopedNameDeclarationAt(declarations, name, content, objectBinding.startIndex) + ) { + declarations.push( + createScopedFsNameFromVariableDeclarator(lines, content, objectBinding, name), + ); + addedAlias = true; + } + } + } + for (const match of content.matchAll( + new RegExp(RAW_FS_NAMESPACE_PROMISES_ALIAS_PATTERN, "g"), + )) { + const name = match?.[1]; + const source = match?.[2]; + if ( + name && + source && + isNamespaceSourceAvailable(content, match, source, namesByLine, shadowedByLine) && + !hasScopedNameDeclaration(declarations, name, content, match) + ) { + declarations.push(createScopedFsName(lines, content, match, name)); + addedAlias = true; + } + } + for (const declarator of variableDeclarators) { + const match = RAW_FS_NAMESPACE_PROMISES_ALIAS_DECLARATOR_PATTERN.exec(declarator.text); + const name = match?.[1]; + const source = match?.[2]; + if ( + name && + source && + isNamespaceSourceAvailableAt( + content, + declarator.startIndex, + source, + namesByLine, + shadowedByLine, + ) && + !hasScopedNameDeclarationAt(declarations, name, content, declarator.startIndex) + ) { + declarations.push( + createScopedFsNameFromVariableDeclarator(lines, content, declarator, name), + ); + addedAlias = true; + } + } + } + return buildScopedNamesByLine(lines, declarations); +} + +function collectDestructuredFsNamesByLine( + lines: readonly string[], + namespaceFsNamesByLine: readonly ReadonlySet[], +) { + const declarations: ScopedFsName[] = []; + const content = lines.join("\n"); + const variableDeclarators = findVariableDeclarators(content); + for (const match of content.matchAll(new RegExp(RAW_FS_DESTRUCTURED_IMPORT_PATTERN, "g"))) { + const specifiers = match?.[1] ?? match?.[2] ?? match?.[3]; + if (!specifiers) continue; + for (const specifier of splitTopLevel(specifiers, ",")) { + for (const name of parseDestructuredFsNames(specifier)) { + declarations.push(createScopedFsName(lines, content, match, name)); + } + } + } + for (const match of content.matchAll( + new RegExp(RAW_FS_COMBINED_DESTRUCTURED_IMPORT_PATTERN, "g"), + )) { + const specifiers = match?.[1]; + if (!specifiers) continue; + for (const specifier of splitTopLevel(specifiers, ",")) { + for (const name of parseDestructuredFsNames(specifier)) { + declarations.push(createScopedFsName(lines, content, match, name)); + } + } + } + for (const match of content.matchAll( + new RegExp(RAW_FS_PROMISES_DESTRUCTURED_IMPORT_PATTERN, "g"), + )) { + const specifiers = match?.[1]; + if (!specifiers) continue; + for (const specifier of splitTopLevel(specifiers, ",")) { + for (const name of parseDestructuredFsNames(specifier)) { + declarations.push(createScopedFsName(lines, content, match, name)); + } + } + } + for (const declaration of findDestructuredFsRuntimeDeclarations(content)) { + for (const specifier of splitTopLevel(declaration.specifiers, ",")) { + for (const name of parseDestructuredFsNames(specifier)) { + declarations.push( + createScopedFsNameFromSource( + lines, + content, + declaration.matchIndex, + declaration.declaration, + name, + ), + ); + } + } + } + const namespaceFsNames = collectNamesFromLines(namespaceFsNamesByLine); + const namespaceShadowsByLine = collectShadowedNamesByLine( + lines, + namespaceFsNames, + collectNewlyActiveNamesByLine(namespaceFsNamesByLine), + ); + for (const match of content.matchAll(new RegExp(RAW_FS_MODULE_MEMBER_ALIAS_PATTERN, "g"))) { + const name = match?.[1]; + if (name) declarations.push(createScopedFsName(lines, content, match, name)); + } + for (const declarator of variableDeclarators) { + const match = RAW_FS_MODULE_MEMBER_ALIAS_DECLARATOR_PATTERN.exec(declarator.text); + const name = match?.[1]; + if (name) + declarations.push(createScopedFsNameFromVariableDeclarator(lines, content, declarator, name)); + } + for (const match of content.matchAll(new RegExp(RAW_FS_NAMESPACE_MEMBER_ALIAS_PATTERN, "g"))) { + const name = match?.[1]; + const source = match?.[2]; + if ( + name && + source && + isNamespaceSourceAvailable( + content, + match, + source, + namespaceFsNamesByLine, + namespaceShadowsByLine, + ) + ) { + declarations.push(createScopedFsName(lines, content, match, name)); + } + } + for (const declarator of variableDeclarators) { + const match = RAW_FS_NAMESPACE_MEMBER_ALIAS_DECLARATOR_PATTERN.exec(declarator.text); + const name = match?.[1]; + const source = match?.[2]; + if ( + name && + source && + isNamespaceSourceAvailableAt( + content, + declarator.startIndex, + source, + namespaceFsNamesByLine, + namespaceShadowsByLine, + ) + ) { + declarations.push(createScopedFsNameFromVariableDeclarator(lines, content, declarator, name)); + } + } + for (const declaration of findRawFsDefaultParameterHelperDeclarations( + content, + namespaceFsNamesByLine, + namespaceShadowsByLine, + )) { + declarations.push( + createScopedFsNameFromSource( + lines, + content, + declaration.matchIndex, + declaration.declaration, + declaration.name, + ), + ); + } + for (const match of content.matchAll( + new RegExp(RAW_FS_NAMESPACE_PROMISES_DESTRUCTURED_PATTERN, "g"), + )) { + const specifiers = match?.[1]; + const source = match?.[2]; + if ( + !specifiers || + !source || + !isNamespaceSourceAvailable( + content, + match, + source, + namespaceFsNamesByLine, + namespaceShadowsByLine, + ) + ) + continue; + for (const specifier of splitTopLevel(specifiers, ",")) { + for (const name of parseDestructuredFsNames(specifier)) { + declarations.push(createScopedFsName(lines, content, match, name)); + } + } + } + for (const declarator of variableDeclarators) { + const objectBinding = parseObjectBindingVariableDeclarator(declarator); + if (!objectBinding) continue; + const match = /^\s*=\s*([A-Za-z_$][\w$]*)\s*(?:\?\.|\.)\s*promises\b/.exec( + objectBinding.source, + ); + const source = match?.[1]; + if ( + !source || + !isNamespaceSourceAvailableAt( + content, + objectBinding.startIndex, + source, + namespaceFsNamesByLine, + namespaceShadowsByLine, + ) + ) + continue; + for (const specifier of splitTopLevel(objectBinding.specifiers, ",")) { + for (const name of parseDestructuredFsNames(specifier)) { + declarations.push( + createScopedFsNameFromVariableDeclarator(lines, content, objectBinding, name), + ); + } + } + } + for (const match of content.matchAll(new RegExp(RAW_FS_NAMESPACE_DESTRUCTURED_PATTERN, "g"))) { + const specifiers = match?.[1]; + const source = match?.[2]; + if ( + !specifiers || + !source || + !isNamespaceSourceAvailable( + content, + match, + source, + namespaceFsNamesByLine, + namespaceShadowsByLine, + ) + ) + continue; + for (const specifier of splitTopLevel(specifiers, ",")) { + for (const name of parseDestructuredFsNames(specifier)) { + declarations.push(createScopedFsName(lines, content, match, name)); + } + } + } + for (const declarator of variableDeclarators) { + const objectBinding = parseObjectBindingVariableDeclarator(declarator); + if (!objectBinding) continue; + const match = /^\s*=\s*\(?\s*([A-Za-z_$][\w$]*)\s*\)?\b/.exec(objectBinding.source); + const source = match?.[1]; + if ( + !source || + !isNamespaceSourceAvailableAt( + content, + objectBinding.startIndex, + source, + namespaceFsNamesByLine, + namespaceShadowsByLine, + ) + ) + continue; + for (const specifier of splitTopLevel(objectBinding.specifiers, ",")) { + for (const name of parseDestructuredFsNames(specifier)) { + declarations.push( + createScopedFsNameFromVariableDeclarator(lines, content, objectBinding, name), + ); + } + } + } + return buildScopedNamesByLine(lines, declarations); +} + +function collectFsSafeNamespaceNamesByLine(lines: readonly string[]) { + const declarations: ScopedFsName[] = []; + const content = lines.join("\n"); + const variableDeclarators = findVariableDeclarators(content); + for (const match of content.matchAll(new RegExp(FS_SAFE_NAMESPACE_PATTERN, "g"))) { + const name = match?.[1] ?? match?.[2]; + if (name) declarations.push(createScopedFsName(lines, content, match, name)); + } + for (const declarator of variableDeclarators) { + const match = FS_SAFE_NAMESPACE_DECLARATOR_PATTERN.exec(declarator.text); + const name = match?.[1]; + if (name) + declarations.push(createScopedFsNameFromVariableDeclarator(lines, content, declarator, name)); + } + return buildScopedNamesByLine(lines, declarations); +} + +function collectFsSafeHelperNamesByLine( + lines: readonly string[], + fsSafeNamespaceNamesByLine: readonly ReadonlySet[], +) { + const declarations: ScopedFsName[] = []; + const content = lines.join("\n"); + const variableDeclarators = findVariableDeclarators(content); + for (const match of content.matchAll(new RegExp(FS_SAFE_DESTRUCTURED_IMPORT_PATTERN, "g"))) { + const specifiers = match?.[1] ?? match?.[2]; + if (!specifiers) continue; + for (const specifier of splitTopLevel(specifiers, ",")) { + for (const name of parseFsSafeHelperNames(specifier)) { + declarations.push(createScopedFsName(lines, content, match, name)); + } + } + } + for (const match of content.matchAll(new RegExp(FS_SAFE_COMBINED_NAMED_IMPORT_PATTERN, "g"))) { + const specifiers = match?.[1]; + if (!specifiers) continue; + for (const specifier of splitTopLevel(specifiers, ",")) { + for (const name of parseFsSafeHelperNames(specifier)) { + declarations.push(createScopedFsName(lines, content, match, name)); + } + } + } + for (const declarator of variableDeclarators) { + const objectBinding = parseObjectBindingVariableDeclarator(declarator); + if (!objectBinding) continue; + if ( + !new RegExp( + String.raw`^\s*=\s*(?:require\(\s*["']${FS_SAFE_MODULE_SPECIFIER_PATTERN}["']\s*\)|await\s+import\(\s*["']${FS_SAFE_MODULE_SPECIFIER_PATTERN}["']\s*\))`, + ).test(objectBinding.source) + ) + continue; + for (const specifier of splitTopLevel(objectBinding.specifiers, ",")) { + for (const name of parseFsSafeHelperNames(specifier)) { + declarations.push( + createScopedFsNameFromVariableDeclarator(lines, content, objectBinding, name), + ); + } + } + } + const fsSafeNamespaceNames = collectNamesFromLines(fsSafeNamespaceNamesByLine); + const fsSafeNamespaceShadowsByLine = collectShadowedNamesByLine( + lines, + fsSafeNamespaceNames, + collectNewlyActiveNamesByLine(fsSafeNamespaceNamesByLine), + ); + for (const match of content.matchAll(new RegExp(FS_SAFE_NAMESPACE_DESTRUCTURED_PATTERN, "g"))) { + const specifiers = match?.[1]; + const source = match?.[2]; + if ( + !specifiers || + !source || + !isNamespaceSourceAvailable( + content, + match, + source, + fsSafeNamespaceNamesByLine, + fsSafeNamespaceShadowsByLine, + ) + ) + continue; + for (const specifier of splitTopLevel(specifiers, ",")) { + for (const name of parseFsSafeHelperNames(specifier)) { + declarations.push(createScopedFsName(lines, content, match, name)); + } + } + } + for (const declarator of variableDeclarators) { + const objectBinding = parseObjectBindingVariableDeclarator(declarator); + if (!objectBinding) continue; + const match = /^\s*=\s*\(?\s*([A-Za-z_$][\w$]*)\s*\)?\b/.exec(objectBinding.source); + const source = match?.[1]; + if ( + !source || + !isNamespaceSourceAvailableAt( + content, + objectBinding.startIndex, + source, + fsSafeNamespaceNamesByLine, + fsSafeNamespaceShadowsByLine, + ) + ) + continue; + for (const specifier of splitTopLevel(objectBinding.specifiers, ",")) { + for (const name of parseFsSafeHelperNames(specifier)) { + declarations.push( + createScopedFsNameFromVariableDeclarator(lines, content, objectBinding, name), + ); + } + } + } + for (const match of content.matchAll(new RegExp(FS_SAFE_NAMESPACE_MEMBER_ALIAS_PATTERN, "g"))) { + const name = match?.[1]; + const source = match?.[2]; + if ( + name && + source && + isNamespaceSourceAvailable( + content, + match, + source, + fsSafeNamespaceNamesByLine, + fsSafeNamespaceShadowsByLine, + ) + ) { + declarations.push(createScopedFsName(lines, content, match, name)); + } + } + for (const declarator of variableDeclarators) { + const match = FS_SAFE_NAMESPACE_MEMBER_ALIAS_DECLARATOR_PATTERN.exec(declarator.text); + const name = match?.[1]; + const source = match?.[2]; + if ( + name && + source && + isNamespaceSourceAvailableAt( + content, + declarator.startIndex, + source, + fsSafeNamespaceNamesByLine, + fsSafeNamespaceShadowsByLine, + ) + ) { + declarations.push(createScopedFsNameFromVariableDeclarator(lines, content, declarator, name)); + } + } + return buildScopedNamesByLine(lines, declarations); +} + +function isNamespaceSourceAvailable( + content: string, + match: RegExpMatchArray, + source: string, + namespaceFsNamesByLine: readonly ReadonlySet[], + shadowedByLine: readonly ReadonlySet[], +) { + return isNamespaceSourceAvailableAt( + content, + match.index ?? 0, + source, + namespaceFsNamesByLine, + shadowedByLine, + ); +} + +function isNamespaceSourceAvailableAt( + content: string, + matchIndex: number, + source: string, + namespaceFsNamesByLine: readonly ReadonlySet[], + shadowedByLine: readonly ReadonlySet[], +) { + const line = lineIndexForContentOffset(content, matchIndex); + if (!(namespaceFsNamesByLine[line] ?? new Set()).has(source)) return false; + return !(shadowedByLine[line] ?? new Set()).has(source); +} + +function findVariableDeclarators(content: string): VariableDeclarator[] { + const declarations: VariableDeclarator[] = []; + const pattern = /\b(?:const|let|var)\b/g; + let match = pattern.exec(content); + while (match) { + const statementStart = match.index; + const declaratorsStart = pattern.lastIndex; + const statementEnd = findVariableDeclarationEnd(content, declaratorsStart); + const declaration = content.slice(statementStart, statementEnd); + const declaratorList = content.slice(declaratorsStart, statementEnd); + for (const declarator of splitTopLevelWithOffsets(declaratorList, ",")) { + const leadingOffset = findFirstNonWhitespaceIndex(declarator.text); + if (leadingOffset === null) continue; + declarations.push({ + text: declarator.text.slice(leadingOffset).trimEnd(), + startIndex: declaratorsStart + declarator.start + leadingOffset, + statementStartIndex: statementStart, + declaration, + }); + } + pattern.lastIndex = Math.min(content.length, statementEnd + 1); + match = pattern.exec(content); + } + return declarations; +} + +function findVariableDeclarationEnd(content: string, startIndex: number) { + let depth = 0; + for (let index = startIndex; index < content.length; index += 1) { + const character = content[index] ?? ""; + if (character === "{" || character === "[" || character === "(") depth += 1; + if (character === "}" || character === "]" || character === ")") depth -= 1; + if (character === ";" && depth === 0) return index; + if ((character === "\n" || character === "\r") && depth === 0) { + if (shouldEndVariableDeclarationAtLineBreak(content, index)) return index; + } + } + return content.length; +} + +function shouldEndVariableDeclarationAtLineBreak(content: string, newlineIndex: number) { + const previousIndex = findPreviousNonWhitespaceIndex(content, newlineIndex - 1); + const previous = previousIndex === null ? "" : (content[previousIndex] ?? ""); + if (!previous) return false; + if (",=?:([{.".includes(previous) || "+-*/%&|^<>!".includes(previous)) return false; + + const nextIndex = findNextNonWhitespaceIndex(content, newlineIndex + 1); + const next = nextIndex === null ? "" : (content[nextIndex] ?? ""); + return next !== "," && next !== "." && next !== "?" && next !== ":"; +} + +function findFirstNonWhitespaceIndex(value: string) { + for (let index = 0; index < value.length; index += 1) { + if (!/\s/.test(value[index] ?? "")) return index; + } + return null; +} + +function findPreviousNonWhitespaceIndex(value: string, startIndex: number) { + for (let index = startIndex; index >= 0; index -= 1) { + if (!/\s/.test(value[index] ?? "")) return index; + } + return null; +} + +function parseObjectBindingVariableDeclarator( + declarator: VariableDeclarator, +): ObjectBindingVariableDeclarator | null { + if (!declarator.text.startsWith("{")) return null; + const closeIndex = findMatchingBrace(declarator.text, 0); + if (closeIndex === null) return null; + return { + ...declarator, + specifiers: declarator.text.slice(1, closeIndex), + source: declarator.text.slice(closeIndex + 1), + }; +} + +function findDestructuredFsRuntimeDeclarations(content: string) { + const declarations: Array<{ specifiers: string; matchIndex: number; declaration: string }> = []; + for (const declarator of findVariableDeclarators(content)) { + const objectBinding = parseObjectBindingVariableDeclarator(declarator); + if (!objectBinding) continue; + const sourceMatch = + /^\s*=\s*(?:require\(\s*["'](?:node:)?fs(?:\/promises)?["']\s*\)|await\s+import\(\s*["'](?:node:)?fs(?:\/promises)?["']\s*\))/.exec( + objectBinding.source, + ); + if (sourceMatch) { + declarations.push({ + specifiers: objectBinding.specifiers, + matchIndex: objectBinding.statementStartIndex, + declaration: objectBinding.declaration, + }); + } + } + return declarations; +} + +function findRawFsDefaultParameterHelperDeclarations( + content: string, + namespaceFsNamesByLine: readonly ReadonlySet[], + namespaceShadowsByLine: readonly ReadonlySet[], +) { + const declarations: Array<{ name: string; matchIndex: number; declaration: string }> = []; + for (const span of findParameterSpans(content)) { + const parameterText = content.slice(span.openIndex + 1, span.closeIndex); + for (const parameter of splitTopLevelWithOffsets(parameterText, ",")) { + const names = new Set(); + const matchIndex = span.openIndex + 1 + parameter.start; + const line = lineIndexForContentOffset(content, matchIndex); + const availableNamespaceNames = new Set(namespaceFsNamesByLine[line] ?? new Set()); + for (const name of namespaceShadowsByLine[line] ?? new Set()) { + availableNamespaceNames.delete(name); + } + collectRawFsDefaultHelperBindingNames(parameter.text, names, availableNamespaceNames); + for (const name of names) + declarations.push({ name, matchIndex, declaration: parameter.text }); + } + } + return declarations; +} + +function findParameterSpans(content: string) { + const spans: ParameterSpan[] = []; + const addSpan = (openIndex: number, closeIndex: number | null) => { + if (closeIndex === null) return; + if (spans.some((span) => span.openIndex === openIndex && span.closeIndex === closeIndex)) + return; + spans.push({ openIndex, closeIndex }); + }; + + for (const match of content.matchAll(/\bfunction\b[^()]*\(/g)) { + const openIndex = content.indexOf("(", match.index); + addSpan(openIndex, findMatchingParen(content, openIndex)); + } + + for (const match of content.matchAll(/=>/g)) { + const arrowIndex = match.index; + const closeIndex = content.lastIndexOf(")", arrowIndex); + const openIndex = findMatchingOpenParen(content, closeIndex); + if (openIndex !== null) addSpan(openIndex, closeIndex); + } + + for (const span of findMethodParameterSpans(content)) { + addSpan(span.openIndex, span.closeIndex); + } + + return spans; +} + +function createScopedFsName( + lines: readonly string[], + content: string, + match: RegExpMatchArray, + name: string, +): ScopedFsName { + const matchIndex = match.index ?? 0; + return createScopedFsNameFromSource(lines, content, matchIndex, match[0] ?? "", name); +} + +function createScopedFsNameFromVariableDeclarator( + lines: readonly string[], + content: string, + declarator: VariableDeclarator, + name: string, +): ScopedFsName { + return createScopedFsNameFromSource( + lines, + content, + declarator.statementStartIndex, + declarator.declaration, + name, + ); +} + +function createScopedFsNameFromSource( + lines: readonly string[], + content: string, + matchIndex: number, + declaration: string, + name: string, +) { + const line = lineIndexForContentOffset(content, matchIndex); + const lineStartIndex = content.lastIndexOf("\n", matchIndex - 1) + 1; + const declarationColumn = matchIndex - lineStartIndex; + const parameterDefaultScopeEndLine = findParameterDefaultAliasScopeEndLineExclusive( + lines, + line, + declarationColumn, + ); + return { + name, + line, + endLineExclusive: + parameterDefaultScopeEndLine ?? + findScopeEndLineExclusive(lines, line, declaration, declarationColumn), + }; +} + +function findParameterDefaultAliasScopeEndLineExclusive( + lines: readonly string[], + lineIndex: number, + declarationColumn: number, +) { + const sameLineScopeEnd = findSameLineParameterDefaultAliasScopeEndLineExclusive( + lines, + lineIndex, + declarationColumn, + ); + if (sameLineScopeEnd !== null) return sameLineScopeEnd; + const signatureStartLine = findMultilineParameterListStartLine(lines, lineIndex); + if (signatureStartLine === null) return null; + for (let index = lineIndex; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + if (!isMultilineParameterListEnd(line)) continue; + if (isMultilineArrowExpressionBodyStart(line)) { + return findMultilineArrowExpressionBodyEndLineExclusive(lines, index); + } + return findBlockBodyEndLineExclusive(lines, index); + } + return null; +} + +function findSameLineParameterDefaultAliasScopeEndLineExclusive( + lines: readonly string[], + lineIndex: number, + declarationColumn: number, +) { + const line = lines[lineIndex] ?? ""; + const functionMatch = /\bfunction\b[^()]*\(/.exec(line); + if (functionMatch) { + const openIndex = line.indexOf("(", functionMatch.index); + const closeIndex = findMatchingParen(line, openIndex); + if (closeIndex !== null && declarationColumn > openIndex && declarationColumn < closeIndex) { + return findBlockBodyEndLineExclusive(lines, lineIndex, closeIndex); + } + } + + const arrowIndex = line.indexOf("=>"); + if (arrowIndex >= 0 && declarationColumn < arrowIndex) { + const closeIndex = line.lastIndexOf(")", arrowIndex); + const openIndex = findMatchingOpenParen(line, closeIndex); + if (openIndex !== null && declarationColumn > openIndex && declarationColumn < closeIndex) { + return ( + findBlockBodyEndLineExclusive(lines, lineIndex, arrowIndex) ?? + findMultilineArrowExpressionBodyEndLineExclusive(lines, lineIndex) ?? + lineIndex + 1 + ); + } + } + + for (const span of findMethodParameterSpans(line)) { + if (declarationColumn > span.openIndex && declarationColumn < span.closeIndex) { + return findBlockBodyEndLineExclusive(lines, lineIndex, span.bodyOpenIndex); + } + } + + return null; +} + +function findMultilineParameterListStartLine(lines: readonly string[], lineIndex: number) { + for (let index = lineIndex; index >= 0; index -= 1) { + const line = lines[index] ?? ""; + if (doesLineStartMultilineParameterList(line)) return index; + if (isMultilineParameterListEnd(line)) return null; + } + return null; +} + +function findBlockBodyEndLineExclusive( + lines: readonly string[], + lineIndex: number, + searchStart = 0, +) { + const line = lines[lineIndex] ?? ""; + const bodyOpenIndex = line.indexOf("{", searchStart); + if (bodyOpenIndex < 0) return null; + const sameLineBodyClose = findMatchingBrace(line, bodyOpenIndex); + if (sameLineBodyClose !== null) return lineIndex + 1; + const lineDepth = depthBeforeLine(lines, lineIndex); + const depthAfterLine = Math.max(0, lineDepth + countBraceDelta(line)); + if (depthAfterLine <= lineDepth) return null; + for (let index = lineIndex + 1; index < lines.length; index += 1) { + if (depthBeforeLine(lines, index) < depthAfterLine) return index; + } + return lines.length; +} + +function findScopeEndLineExclusive( + lines: readonly string[], + lineIndex: number, + declaration: string, + declarationColumn: number, +) { + if (isModuleScopedDeclaration(declaration)) return lines.length; + + const line = lines[lineIndex] ?? ""; + if (findSameLineDeclarationScopeEnd(line, declarationColumn) !== null) return lineIndex + 1; + const lineDepth = depthBeforeLine(lines, lineIndex); + const depthAfterLine = Math.max(0, lineDepth + countBraceDelta(line)); + + const scopeDepth = depthAfterLine > lineDepth ? depthAfterLine : lineDepth; + if (scopeDepth === 0) return lines.length; + for (let index = lineIndex + 1; index < lines.length; index += 1) { + if (depthBeforeLine(lines, index) < scopeDepth) return index; + } + return lines.length; +} + +function isModuleScopedDeclaration(declaration: string) { + return /^\s*import\s+(?!\()/.test(declaration); +} + +function buildScopedNamesByLine(lines: readonly string[], declarations: readonly ScopedFsName[]) { + return lines.map((_, line) => { + const names = new Set(); + for (const declaration of declarations) { + if (line >= declaration.line && line < declaration.endLineExclusive) { + names.add(declaration.name); + } + } + return names; + }); +} + +function collectNamesFromLines(namesByLine: readonly ReadonlySet[]) { + const names = new Set(); + for (const lineNames of namesByLine) { + for (const name of lineNames) names.add(name); + } + return names; +} + +function collectNewlyActiveNamesByLine(namesByLine: readonly ReadonlySet[]) { + let previousNames = new Set(); + return namesByLine.map((lineNames) => { + const newNames = new Set(); + for (const name of lineNames) { + if (!previousNames.has(name)) newNames.add(name); + } + previousNames = new Set(lineNames); + return newNames; + }); +} + +function subtractShadowedNamesByLine( + namesByLine: readonly ReadonlySet[], + shadowedNamesByLine: readonly ReadonlySet[], +) { + return namesByLine.map((lineNames, lineIndex) => { + const names = new Set(lineNames); + for (const shadowedName of shadowedNamesByLine[lineIndex] ?? new Set()) { + names.delete(shadowedName); + } + return names; + }); +} + +function hasScopedNameDeclaration( + declarations: readonly ScopedFsName[], + name: string, + content: string, + match: RegExpMatchArray, +) { + return hasScopedNameDeclarationAt(declarations, name, content, match.index ?? 0); +} + +function hasScopedNameDeclarationAt( + declarations: readonly ScopedFsName[], + name: string, + content: string, + matchIndex: number, +) { + const line = lineIndexForContentOffset(content, matchIndex); + return declarations.some((declaration) => declaration.name === name && declaration.line === line); +} + +function parseDestructuredFsNames(specifier: string) { + const cleaned = stripTopLevelDefault(specifier.trim()).trim(); + const importAlias = /^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/.exec(cleaned); + if (importAlias) { + const importedName = importAlias[1]; + const localName = importAlias[2]; + return importedName && localName && RAW_FS_DESTRUCTURED_NAMES.has(importedName) + ? [localName] + : []; + } + + const requireAlias = /^([A-Za-z_$][\w$]*)\s*:\s*([A-Za-z_$][\w$]*)$/.exec(cleaned); + if (requireAlias) { + const importedName = requireAlias[1]; + const localName = requireAlias[2]; + return importedName && localName && RAW_FS_DESTRUCTURED_NAMES.has(importedName) + ? [localName] + : []; + } + + const nestedAlias = /^([A-Za-z_$][\w$]*)\s*:\s*([{[].*)$/.exec(cleaned); + if (nestedAlias?.[1] === "promises" && nestedAlias[2]) { + const names = new Set(); + collectFsHelperBindingNames(nestedAlias[2], names); + return [...names]; + } + + const identifier = /^([A-Za-z_$][\w$]*)$/.exec(cleaned); + const name = identifier?.[1]; + return name && RAW_FS_DESTRUCTURED_NAMES.has(name) ? [name] : []; +} + +function collectFsHelperBindingNames(binding: string, names: Set) { + const cleaned = stripTopLevelDefault(binding.trim()).trim(); + if (!cleaned) return; + const withoutRest = cleaned.startsWith("...") ? cleaned.slice(3).trim() : cleaned; + if (withoutRest.startsWith("{") && withoutRest.endsWith("}")) { + for (const property of splitTopLevel(withoutRest.slice(1, -1), ",")) { + collectFsHelperObjectBindingPropertyNames(property, names); + } + return; + } + + const identifier = /^([A-Za-z_$][\w$]*)$/.exec(withoutRest)?.[1]; + if (identifier && RAW_FS_DESTRUCTURED_NAMES.has(identifier)) names.add(identifier); +} + +function collectFsHelperObjectBindingPropertyNames(property: string, names: Set) { + const cleaned = stripTopLevelDefault(property.trim()).trim(); + if (!cleaned) return; + const withoutRest = cleaned.startsWith("...") ? cleaned.slice(3).trim() : cleaned; + const colonIndex = findTopLevelCharacter(withoutRest, ":"); + if (colonIndex >= 0) { + const propertyName = /^([A-Za-z_$][\w$]*)/.exec(withoutRest.slice(0, colonIndex).trim())?.[1]; + const target = withoutRest.slice(colonIndex + 1).trim(); + if (propertyName === "promises") { + collectFsHelperBindingNames(target, names); + } else if (propertyName && RAW_FS_DESTRUCTURED_NAMES.has(propertyName)) { + collectBindingNames(target, names); + } + return; + } + + const identifier = /^([A-Za-z_$][\w$]*)/.exec(withoutRest)?.[1]; + if (identifier && RAW_FS_DESTRUCTURED_NAMES.has(identifier)) names.add(identifier); +} + +function parseDestructuredFsPromisesName(specifier: string) { + const cleaned = stripTopLevelDefault(specifier.trim()).trim(); + const importAlias = /^promises\s+as\s+([A-Za-z_$][\w$]*)$/.exec(cleaned); + if (importAlias) return importAlias[1] ?? null; + + const requireAlias = /^promises\s*:\s*([A-Za-z_$][\w$]*)$/.exec(cleaned); + if (requireAlias) return requireAlias[1] ?? null; + + return cleaned === "promises" ? "promises" : null; +} + +function parseFsSafeHelperNames(specifier: string) { + const cleaned = stripTopLevelDefault(specifier.trim()).trim(); + if (!cleaned || cleaned.startsWith("type ")) return []; + + const importAlias = /^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/.exec(cleaned); + if (importAlias) { + const importedName = importAlias[1]; + const localName = importAlias[2]; + return importedName && localName && FS_SAFE_HELPER_NAMES.has(importedName) ? [localName] : []; + } + + const requireAlias = /^([A-Za-z_$][\w$]*)\s*:\s*([A-Za-z_$][\w$]*)$/.exec(cleaned); + if (requireAlias) { + const importedName = requireAlias[1]; + const localName = requireAlias[2]; + return importedName && localName && FS_SAFE_HELPER_NAMES.has(importedName) ? [localName] : []; + } + + const identifier = /^([A-Za-z_$][\w$]*)$/.exec(cleaned); + const name = identifier?.[1]; + return name && FS_SAFE_HELPER_NAMES.has(name) ? [name] : []; +} + +function hasDestructuredFsCall( + line: string, + names: ReadonlySet, + shadowedNames: ReadonlySet, +) { + for (const name of names) { + const pattern = new RegExp(`(^|[^.\\w$])${escapeRegExp(name)}\\s*(?:\\?\\.\\s*)?\\(`, "g"); + let match = pattern.exec(line); + while (match) { + const usageIndex = match.index + (match[1]?.length ?? 0); + if (!isNameShadowedAtUsage(line, name, shadowedNames, usageIndex)) return true; + match = pattern.exec(line); + } + } + return false; +} + +function hasNamespaceFsSafeCall( + line: string, + names: ReadonlySet, + shadowedNames: ReadonlySet, +) { + for (const name of names) { + const pattern = new RegExp( + `\\b${escapeRegExp(name)}${OPTIONAL_MEMBER_ACCESS_PATTERN}(?:${FS_SAFE_HELPER_PATTERN})\\b`, + "g", + ); + let match = pattern.exec(line); + while (match) { + if (!isNameShadowedAtUsage(line, name, shadowedNames, match.index)) return true; + match = pattern.exec(line); + } + } + return false; +} + +function hasNamespaceFsCall( + line: string, + names: ReadonlySet, + shadowedNames: ReadonlySet, +) { + for (const name of names) { + const pattern = new RegExp( + `\\b${escapeRegExp(name)}${OPTIONAL_MEMBER_ACCESS_PATTERN}(?:${RAW_FS_METHOD_PATTERN}|promises${OPTIONAL_MEMBER_ACCESS_PATTERN}(?:${RAW_FS_METHOD_PATTERN}))\\b`, + "g", + ); + let match = pattern.exec(line); + while (match) { + if (!isNameShadowedAtUsage(line, name, shadowedNames, match.index)) return true; + match = pattern.exec(line); + } + } + return false; +} + +function isNameShadowedAtUsage( + line: string, + name: string, + shadowedNames: ReadonlySet, + usageIndex: number, +) { + if (!shadowedNames.has(name)) return false; + const ranges = findNameShadowRanges(line, name); + if (ranges.length === 0) return true; + return ranges.some((range) => usageIndex >= range.start && usageIndex < range.end); +} + +function collectShadowedNamesByLine( + lines: readonly string[], + names: ReadonlySet, + fsAliasNamesByLine: readonly ReadonlySet[] = [], + rawFsDefaultNamespaceNamesByLine: readonly ReadonlySet[] = [], +) { + const shadowedByLine: Array> = []; + const activeShadows: Array<{ name: string; depth: number; endLineExclusive?: number }> = []; + const activeFunctionScopeDepths: number[] = []; + let pendingParameterLines: string[] | null = null; + let depth = 0; + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { + const line = lines[lineIndex] ?? ""; + const fsAliasNames = fsAliasNamesByLine[lineIndex] ?? new Set(); + const rawFsDefaultNamespaceNames = rawFsDefaultNamespaceNamesByLine[lineIndex] ?? new Set(); + const functionBodyOpenIndices = findFunctionBodyOpenIndices(line); + const active = new Set(); + for (const shadow of activeShadows) { + if ( + depth >= shadow.depth && + (shadow.endLineExclusive === undefined || lineIndex < shadow.endLineExclusive) + ) { + active.add(shadow.name); + } + } + + for (const name of names) { + if (isNameShadowedOnLine(line, name, fsAliasNames, rawFsDefaultNamespaceNames)) + active.add(name); + } + + let completedParameterShadows: Set | null = null; + if (pendingParameterLines) { + pendingParameterLines.push(line); + const pendingParameterText = pendingParameterLines.join("\n"); + if (isMultilineParameterListEnd(line)) { + completedParameterShadows = collectParameterShadowedNames( + pendingParameterText, + names, + rawFsDefaultNamespaceNames, + ); + pendingParameterLines = null; + } else if (isMultilineParameterListClosed(pendingParameterText)) { + pendingParameterLines = null; + } + } + if (completedParameterShadows) { + for (const name of completedParameterShadows) active.add(name); + } + + shadowedByLine.push(active); + + if ( + !completedParameterShadows && + !pendingParameterLines && + doesLineStartMultilineParameterList(line) + ) { + pendingParameterLines = [line]; + } + + const depthAfterLine = Math.max(0, depth + countBraceDelta(line)); + const shadowIntroductions = [ + ...names, + ...(completedParameterShadows ? [...completedParameterShadows] : []), + ].filter((name, index, allNames) => { + if (allNames.indexOf(name) !== index) return false; + return ( + isShadowIntroducedOnLine(line, name, fsAliasNames, rawFsDefaultNamespaceNames) || + (completedParameterShadows?.has(name) && + (depthAfterLine > depth || isMultilineArrowExpressionBodyStart(line))) + ); + }); + for (const name of shadowIntroductions) { + if (!shouldPersistShadow(line, name, depth, depthAfterLine, fsAliasNames)) continue; + const endLineExclusive = completedParameterShadows?.has(name) + ? findMultilineArrowExpressionBodyEndLineExclusive(lines, lineIndex) + : null; + activeShadows.push({ + name, + depth: shadowDepthForLine( + line, + name, + depth, + depthAfterLine, + activeFunctionScopeDepths, + functionBodyOpenIndices, + ), + ...(endLineExclusive === null ? {} : { endLineExclusive }), + }); + } + const openedFunctionScopeDepths = functionBodyOpenIndices.map((bodyOpenIndex) => + depthAfterColumn(line, bodyOpenIndex, depth), + ); + depth = depthAfterLine; + activeFunctionScopeDepths.push(...openedFunctionScopeDepths); + removeClosedFunctionScopes(activeFunctionScopeDepths, depth); + for (let index = activeShadows.length - 1; index >= 0; index -= 1) { + const shadow = activeShadows[index]; + if ( + shadow && + (depth < shadow.depth || + (shadow.endLineExclusive !== undefined && lineIndex + 1 >= shadow.endLineExclusive)) + ) { + activeShadows.splice(index, 1); + } + } + } + + return shadowedByLine; +} + +function isNameShadowedOnLine( + line: string, + name: string, + fsAliasNames: ReadonlySet, + rawFsDefaultNamespaceNames: ReadonlySet, +) { + return ( + isParameterShadowedOnLine(line, name, rawFsDefaultNamespaceNames) || + isLocalDeclarationShadow(line, name, fsAliasNames) || + isCatchBindingShadow(line, name) + ); +} + +function isShadowIntroducedOnLine( + line: string, + name: string, + fsAliasNames: ReadonlySet, + rawFsDefaultNamespaceNames: ReadonlySet, +) { + return ( + isParameterShadowedOnLine(line, name, rawFsDefaultNamespaceNames) || + isLocalDeclarationShadow(line, name, fsAliasNames) || + isCatchBindingShadow(line, name) + ); +} + +function isParameterShadowedOnLine( + line: string, + name: string, + rawFsDefaultNamespaceNames: ReadonlySet = new Set(), +) { + return collectParameterShadowedNames(line, new Set([name]), rawFsDefaultNamespaceNames).has(name); +} + +function findNameShadowRanges(line: string, name: string) { + const ranges: Array<{ start: number; end: number }> = []; + const parameterRange = findParameterShadowRange(line, name); + if (parameterRange) ranges.push(parameterRange); + const variableStart = findVariableDeclarationShadowStartIndex(line, name); + if (variableStart >= 0) + ranges.push( + isVarDeclarationShadow(line, name) + ? findSameLineVarShadowRange(line, variableStart) + : findSameLineShadowRange(line, variableStart), + ); + const destructuredStart = findDestructuredVariableDeclarationShadowStartIndex(line, name); + if (destructuredStart >= 0) + ranges.push( + isVarDestructuredDeclarationShadow(line, name) + ? findSameLineVarShadowRange(line, destructuredStart) + : findSameLineShadowRange(line, destructuredStart), + ); + const functionStart = findFunctionDeclarationShadowStartIndex(line, name); + if (functionStart >= 0) ranges.push(findSameLineShadowRange(line, functionStart)); + const catchStart = findCatchBindingShadowStartIndex(line, name); + if (catchStart >= 0) ranges.push(findSameLineCatchShadowRange(line, catchStart)); + return ranges; +} + +function findSameLineVarShadowRange(line: string, shadowStart: number) { + return { + start: shadowStart, + end: findSameLineEnclosingFunctionBodyEnd(line, shadowStart) ?? line.length, + }; +} + +function findSameLineEnclosingFunctionBodyEnd(line: string, index: number) { + let enclosingBodyOpenIndex: number | null = null; + for (const bodyOpenIndex of findFunctionBodyOpenIndices(line)) { + if (bodyOpenIndex >= index) continue; + const bodyCloseIndex = findMatchingBrace(line, bodyOpenIndex); + if (bodyCloseIndex === null || index >= bodyCloseIndex) continue; + if (enclosingBodyOpenIndex === null || bodyOpenIndex > enclosingBodyOpenIndex) { + enclosingBodyOpenIndex = bodyOpenIndex; + } + } + if (enclosingBodyOpenIndex === null) return null; + const bodyCloseIndex = findMatchingBrace(line, enclosingBodyOpenIndex); + return bodyCloseIndex === null ? null : bodyCloseIndex + 1; +} + +function findSameLineShadowRange(line: string, shadowStart: number) { + const scopeEnd = findSameLineDeclarationScopeEnd(line, shadowStart); + if (scopeEnd !== null) return { start: shadowStart, end: scopeEnd }; + const statementEnd = findSameLineStatementEnd(line, shadowStart); + if (statementEnd !== null) return { start: shadowStart, end: statementEnd }; + return { start: shadowStart, end: line.length }; +} + +function findSameLineDeclarationScopeEnd(line: string, declarationColumn: number) { + const blockOpenBeforeDeclaration = line.lastIndexOf("{", declarationColumn); + if (blockOpenBeforeDeclaration >= 0) { + const closeBraceIndex = findMatchingBrace(line, blockOpenBeforeDeclaration); + if (closeBraceIndex !== null && closeBraceIndex >= declarationColumn) + return closeBraceIndex + 1; + } + + const bodyOpenAfterDeclaration = line.indexOf("{", declarationColumn); + if (bodyOpenAfterDeclaration < 0) return null; + const beforeBody = line.slice(0, bodyOpenAfterDeclaration); + if (!/\bfunction\b/.test(beforeBody) && !/=>\s*$/.test(beforeBody)) return null; + const closeBraceIndex = findMatchingBrace(line, bodyOpenAfterDeclaration); + return closeBraceIndex === null ? null : closeBraceIndex + 1; +} + +function findSameLineStatementEnd(line: string, startIndex: number) { + let depth = 0; + for (let index = startIndex; index < line.length; index += 1) { + const character = line[index] ?? ""; + if (character === "{" || character === "[" || character === "(") depth += 1; + if (character === "}" || character === "]" || character === ")") depth -= 1; + if (character === ";" && depth === 0) return index + 1; + } + return null; +} + +function findParameterShadowRange(line: string, name: string) { + if (!isParameterShadowedOnLine(line, name)) return null; + + const functionMatch = /\bfunction\b[^()]*\(/.exec(line); + if (functionMatch) { + const openIndex = line.indexOf("(", functionMatch.index); + const closeIndex = findMatchingParen(line, openIndex); + if ( + closeIndex !== null && + collectBoundParameterListNames(line.slice(openIndex + 1, closeIndex)).has(name) + ) { + return { + start: functionMatch.index, + end: findSameLineFunctionBodyEnd(line, closeIndex), + }; + } + } + + const arrowIndex = line.indexOf("=>"); + if (arrowIndex >= 0) { + const beforeArrow = line.slice(0, arrowIndex).trimEnd(); + const closeIndex = line.lastIndexOf(")", arrowIndex); + const openIndex = findMatchingOpenParen(line, closeIndex); + if ( + openIndex !== null && + collectBoundParameterListNames(line.slice(openIndex + 1, closeIndex)).has(name) + ) { + return { + start: openIndex, + end: findSameLineArrowBodyEnd(line, arrowIndex), + }; + } else { + const match = new RegExp(`\\b${escapeRegExp(name)}\\s*$`).exec(beforeArrow); + if (match) { + return { + start: match.index, + end: findSameLineArrowBodyEnd(line, arrowIndex), + }; + } + } + } + + for (const span of findMethodParameterSpans(line)) { + if (collectBoundParameterListNames(line.slice(span.openIndex + 1, span.closeIndex)).has(name)) { + return { + start: span.signatureStartIndex, + end: findSameLineMethodBodyEnd(line, span.bodyOpenIndex), + }; + } + } + + return null; +} + +function findSameLineFunctionBodyEnd(line: string, parameterCloseIndex: number) { + const bodyOpenIndex = line.indexOf("{", parameterCloseIndex); + if (bodyOpenIndex < 0) return line.length; + const bodyCloseIndex = findMatchingBrace(line, bodyOpenIndex); + return bodyCloseIndex === null ? line.length : bodyCloseIndex + 1; +} + +function findSameLineArrowBodyEnd(line: string, arrowIndex: number) { + const bodyStart = findNextNonWhitespaceIndex(line, arrowIndex + 2); + if (bodyStart !== null && line[bodyStart] === "{") { + const bodyCloseIndex = findMatchingBrace(line, bodyStart); + return bodyCloseIndex === null ? line.length : bodyCloseIndex + 1; + } + const semicolonIndex = line.indexOf(";", arrowIndex + 2); + return semicolonIndex < 0 ? line.length : semicolonIndex + 1; +} + +function findSameLineMethodBodyEnd(line: string, bodyOpenIndex: number) { + const bodyCloseIndex = findMatchingBrace(line, bodyOpenIndex); + return bodyCloseIndex === null ? line.length : bodyCloseIndex + 1; +} + +function findSameLineCatchShadowRange(line: string, catchStart: number) { + const openIndex = line.indexOf("(", catchStart); + const closeIndex = findMatchingParen(line, openIndex); + if (closeIndex !== null) { + const bodyOpenIndex = findNextNonWhitespaceIndex(line, closeIndex + 1); + if (bodyOpenIndex !== null && line[bodyOpenIndex] === "{") { + const bodyCloseIndex = findMatchingBrace(line, bodyOpenIndex); + if (bodyCloseIndex !== null) return { start: catchStart, end: bodyCloseIndex + 1 }; + } + } + return { start: catchStart, end: line.length }; +} + +function findFunctionBodyOpenIndices(line: string) { + const indices = new Set(); + + for (const match of line.matchAll(/\bfunction\b[^()]*\(/g)) { + const openIndex = line.indexOf("(", match.index); + const closeIndex = findMatchingParen(line, openIndex); + if (closeIndex === null) continue; + const bodyOpenIndex = findNextNonWhitespaceIndex(line, closeIndex + 1); + if (bodyOpenIndex !== null && line[bodyOpenIndex] === "{") indices.add(bodyOpenIndex); + } + + for (const match of line.matchAll(/=>/g)) { + const bodyOpenIndex = findNextNonWhitespaceIndex(line, match.index + 2); + if (bodyOpenIndex !== null && line[bodyOpenIndex] === "{") indices.add(bodyOpenIndex); + } + + for (const span of findMethodParameterSpans(line)) { + indices.add(span.bodyOpenIndex); + } + + return [...indices].sort((left, right) => left - right); +} + +function depthAfterColumn(line: string, column: number, initialDepth: number) { + let depth = initialDepth; + for (let index = 0; index <= column && index < line.length; index += 1) { + const character = line[index] ?? ""; + if (character === "{") depth += 1; + if (character === "}") depth = Math.max(0, depth - 1); + } + return depth; +} + +function removeClosedFunctionScopes(functionScopeDepths: number[], depth: number) { + for (let index = functionScopeDepths.length - 1; index >= 0; index -= 1) { + if ((functionScopeDepths[index] ?? 0) > depth) functionScopeDepths.splice(index, 1); + } +} + +function findNextNonWhitespaceIndex(line: string, startIndex: number) { + for (let index = startIndex; index < line.length; index += 1) { + if (!/\s/.test(line[index] ?? "")) return index; + } + return null; +} + +function collectParameterShadowedNames( + text: string, + names: ReadonlySet, + rawFsDefaultNamespaceNames: ReadonlySet = new Set(), +) { + const boundNames = collectBoundParameterNames(text); + const rawFsDefaultNames = collectRawFsDefaultParameterNames(text, rawFsDefaultNamespaceNames); + const shadowed = new Set(); + for (const name of names) { + if (boundNames.has(name) && !rawFsDefaultNames.has(name)) shadowed.add(name); + } + return shadowed; +} + +function collectRawFsDefaultParameterNames( + text: string, + availableNamespaceNames: ReadonlySet, +) { + const names = new Set(); + for (const parameterText of extractParameterTexts(text)) { + for (const parameter of splitTopLevel(parameterText, ",")) { + collectRawFsDefaultBindingNames(parameter, names, availableNamespaceNames); + } + } + return names; +} + +function collectRawFsDefaultBindingNames( + binding: string, + names: Set, + availableNamespaceNames: ReadonlySet, +) { + const cleaned = stripTopLevelTypeAnnotation(binding.trim()).trim(); + if (!cleaned) return; + const withoutRest = cleaned.startsWith("...") ? cleaned.slice(3).trim() : cleaned; + const defaultIndex = findTopLevelCharacter(withoutRest, "="); + if (defaultIndex >= 0) { + const target = withoutRest.slice(0, defaultIndex).trim(); + const defaultValue = withoutRest.slice(defaultIndex + 1); + if ( + RAW_FS_MODULE_PATTERN.test(defaultValue) || + hasRawFsHelperReference(defaultValue, availableNamespaceNames) + ) { + collectBindingNames(target, names); + return; + } + collectRawFsDefaultBindingNames(target, names, availableNamespaceNames); + return; + } + + if (withoutRest.startsWith("{") && withoutRest.endsWith("}")) { + for (const property of splitTopLevel(withoutRest.slice(1, -1), ",")) { + collectRawFsDefaultObjectPropertyNames(property, names, availableNamespaceNames); + } + return; + } + + if (withoutRest.startsWith("[") && withoutRest.endsWith("]")) { + for (const item of splitTopLevel(withoutRest.slice(1, -1), ",")) { + collectRawFsDefaultBindingNames(item, names, availableNamespaceNames); + } + } +} + +function collectRawFsDefaultObjectPropertyNames( + property: string, + names: Set, + availableNamespaceNames: ReadonlySet, +) { + const cleaned = property.trim(); + if (!cleaned) return; + const withoutRest = cleaned.startsWith("...") ? cleaned.slice(3).trim() : cleaned; + const colonIndex = findTopLevelCharacter(withoutRest, ":"); + collectRawFsDefaultBindingNames( + colonIndex >= 0 ? withoutRest.slice(colonIndex + 1) : withoutRest, + names, + availableNamespaceNames, + ); +} + +function collectRawFsDefaultHelperBindingNames( + binding: string, + names: Set, + availableNamespaceNames: ReadonlySet, +) { + const cleaned = stripTopLevelTypeAnnotation(binding.trim()).trim(); + if (!cleaned) return; + const withoutRest = cleaned.startsWith("...") ? cleaned.slice(3).trim() : cleaned; + + if (withoutRest.startsWith("{") && withoutRest.endsWith("}")) { + for (const property of splitTopLevel(withoutRest.slice(1, -1), ",")) { + collectRawFsDefaultHelperObjectPropertyNames(property, names, availableNamespaceNames); + } + return; + } + + if (withoutRest.startsWith("[") && withoutRest.endsWith("]")) { + for (const item of splitTopLevel(withoutRest.slice(1, -1), ",")) { + collectRawFsDefaultHelperBindingNames(item, names, availableNamespaceNames); + } + return; + } + + const defaultIndex = findTopLevelCharacter(withoutRest, "="); + if (defaultIndex < 0) return; + const target = withoutRest.slice(0, defaultIndex).trim(); + const defaultValue = withoutRest.slice(defaultIndex + 1); + if (hasRawFsHelperReference(defaultValue, availableNamespaceNames)) + collectBindingNames(target, names); +} + +function collectRawFsDefaultHelperObjectPropertyNames( + property: string, + names: Set, + availableNamespaceNames: ReadonlySet, +) { + const cleaned = property.trim(); + if (!cleaned) return; + const withoutRest = cleaned.startsWith("...") ? cleaned.slice(3).trim() : cleaned; + const colonIndex = findTopLevelCharacter(withoutRest, ":"); + collectRawFsDefaultHelperBindingNames( + colonIndex >= 0 ? withoutRest.slice(colonIndex + 1) : withoutRest, + names, + availableNamespaceNames, + ); +} + +function hasRawFsHelperReference(value: string, availableNamespaceNames: ReadonlySet) { + const helperPattern = `(?:promises\\s*\\.\\s*)?(?:${RAW_FS_METHOD_PATTERN})\\b`; + if (RAW_FS_MODULE_PATTERN.test(value) && new RegExp(`\\.\\s*${helperPattern}`).test(value)) { + return true; + } + for (const name of availableNamespaceNames) { + if (new RegExp(`\\b${escapeRegExp(name)}\\s*\\.\\s*${helperPattern}`).test(value)) return true; + } + return false; +} + +function collectBoundParameterNames(text: string) { + const boundNames = new Set(); + for (const parameterText of extractParameterTexts(text)) { + for (const name of collectBoundParameterListNames(parameterText)) boundNames.add(name); + } + return boundNames; +} + +function collectBoundParameterListNames(parameterText: string) { + const boundNames = new Set(); + for (const parameter of splitTopLevel(parameterText, ",")) { + collectBindingNames(parameter, boundNames); + } + return boundNames; +} + +function extractParameterTexts(text: string) { + const parameters: string[] = []; + const functionMatch = /\bfunction\b[^()]*\(/.exec(text); + if (functionMatch) { + const openIndex = text.indexOf("(", functionMatch.index); + const closeIndex = findMatchingParen(text, openIndex); + if (closeIndex !== null) parameters.push(text.slice(openIndex + 1, closeIndex)); + } + + const arrowIndex = text.indexOf("=>"); + if (arrowIndex >= 0) { + const beforeArrow = text.slice(0, arrowIndex).trimEnd(); + const closeIndex = text.lastIndexOf(")", arrowIndex); + if (closeIndex >= 0) { + const openIndex = findMatchingOpenParen(text, closeIndex); + if (openIndex !== null) parameters.push(text.slice(openIndex + 1, closeIndex)); + } else { + const bareParameter = /([A-Za-z_$][\w$]*)\s*$/.exec(beforeArrow)?.[1]; + if (bareParameter) parameters.push(bareParameter); + } + } + + for (const span of findMethodParameterSpans(text)) { + parameters.push(text.slice(span.openIndex + 1, span.closeIndex)); + } + + return parameters; +} + +function findMethodParameterSpans(text: string) { + const spans: Array<{ + signatureStartIndex: number; + openIndex: number; + closeIndex: number; + bodyOpenIndex: number; + }> = []; + for (let openIndex = 0; openIndex < text.length; openIndex += 1) { + if (text[openIndex] !== "(") continue; + const signatureStartIndex = findMethodSignatureStartIndex(text, openIndex); + if (signatureStartIndex === null) continue; + const closeIndex = findMatchingParen(text, openIndex); + if (closeIndex === null) continue; + const bodyOpenIndex = findMethodBodyOpenIndex(text, closeIndex); + if (bodyOpenIndex === null) continue; + spans.push({ signatureStartIndex, openIndex, closeIndex, bodyOpenIndex }); + } + return spans; +} + +function findMethodSignatureStartIndex(text: string, openIndex: number) { + const prefix = text.slice(0, openIndex); + const segmentStart = findMethodSignatureSegmentStart(prefix); + const segment = prefix.slice(segmentStart); + const trimmedStart = segment.search(/\S/); + if (trimmedStart < 0) return null; + return isMethodSignaturePrefix(segment) ? segmentStart + trimmedStart : null; +} + +function findMethodSignatureSegmentStart(prefix: string) { + let segmentStart = 0; + for (let index = prefix.length - 1; index >= 0; index -= 1) { + const character = prefix[index] ?? ""; + if (character === "{" || character === "}" || character === ";" || character === ",") { + segmentStart = index + 1; + break; + } + } + return segmentStart; +} + +function isMethodSignaturePrefix(prefix: string) { + const trimmed = prefix.trim(); + if (!trimmed || trimmed.includes("=") || trimmed.includes("=>")) return false; + if (/\bfunction\b/.test(trimmed)) return false; + if (/^(?:if|for|while|switch|catch|with|return|throw|new)\b/.test(trimmed)) return false; + return /^(?:(?:public|private|protected|static|readonly|abstract|override|async|accessor|get|set)\s+)*(?:\*\s*)?(?:(?:#?[A-Za-z_$][\w$]*\??)(?:\s*<[^<>\n]*>)?|["'][^"'\n]+["']|\[[^\]\n]+\])$/.test( + trimmed, + ); +} + +function findMethodBodyOpenIndex(text: string, closeIndex: number) { + const suffix = text.slice(closeIndex + 1); + const match = /^\s*(?::[^;=]*)?\s*\{/.exec(suffix); + return match ? closeIndex + 1 + match[0].lastIndexOf("{") : null; +} + +function collectBindingNames(binding: string, names: Set) { + const cleaned = stripTopLevelTypeAnnotation(stripTopLevelDefault(binding.trim()).trim()).trim(); + if (!cleaned) return; + const withoutRest = cleaned.startsWith("...") ? cleaned.slice(3).trim() : cleaned; + if (withoutRest.startsWith("{") && withoutRest.endsWith("}")) { + collectObjectBindingNames(withoutRest.slice(1, -1), names); + return; + } + if (withoutRest.startsWith("[") && withoutRest.endsWith("]")) { + for (const item of splitTopLevel(withoutRest.slice(1, -1), ",")) { + collectBindingNames(item, names); + } + return; + } + + const identifier = /^([A-Za-z_$][\w$]*)/.exec(withoutRest)?.[1]; + if (identifier) names.add(identifier); +} + +function stripTopLevelTypeAnnotation(value: string) { + if (!value) return value; + const withoutRest = value.startsWith("...") ? value.slice(3).trimStart() : value; + const restPrefix = value.slice(0, value.length - withoutRest.length); + if (withoutRest.startsWith("{") || withoutRest.startsWith("[")) { + const closeIndex = + withoutRest[0] === "{" + ? findMatchingBrace(withoutRest, 0) + : findMatchingBracket(withoutRest, 0); + if (closeIndex !== null) { + const suffix = withoutRest.slice(closeIndex + 1).trimStart(); + if (suffix.startsWith(":")) return restPrefix + withoutRest.slice(0, closeIndex + 1); + } + return value; + } + + const typeIndex = findTopLevelCharacter(withoutRest, ":"); + return typeIndex >= 0 ? restPrefix + withoutRest.slice(0, typeIndex) : value; +} + +function collectObjectBindingNames(binding: string, names: Set) { + for (const property of splitTopLevel(binding, ",")) { + const cleaned = stripTopLevelDefault(property.trim()).trim(); + if (!cleaned) continue; + const withoutRest = cleaned.startsWith("...") ? cleaned.slice(3).trim() : cleaned; + const colonIndex = findTopLevelCharacter(withoutRest, ":"); + if (colonIndex >= 0) { + collectBindingNames(withoutRest.slice(colonIndex + 1), names); + continue; + } + const identifier = /^([A-Za-z_$][\w$]*)/.exec(withoutRest)?.[1]; + if (identifier) names.add(identifier); + } +} + +function splitTopLevel(value: string, delimiter: ",") { + const parts: string[] = []; + let partStart = 0; + let depth = 0; + for (let index = 0; index < value.length; index += 1) { + const character = value[index] ?? ""; + if (character === "{" || character === "[" || character === "(") depth += 1; + if (character === "}" || character === "]" || character === ")") depth -= 1; + if (character === delimiter && depth === 0) { + parts.push(value.slice(partStart, index)); + partStart = index + 1; + } + } + parts.push(value.slice(partStart)); + return parts; +} + +function splitTopLevelWithOffsets(value: string, delimiter: ",") { + const parts: Array<{ text: string; start: number }> = []; + let partStart = 0; + let depth = 0; + for (let index = 0; index < value.length; index += 1) { + const character = value[index] ?? ""; + if (character === "{" || character === "[" || character === "(") depth += 1; + if (character === "}" || character === "]" || character === ")") depth -= 1; + if (character === delimiter && depth === 0) { + parts.push({ text: value.slice(partStart, index), start: partStart }); + partStart = index + 1; + } + } + parts.push({ text: value.slice(partStart), start: partStart }); + return parts; +} + +function stripTopLevelDefault(value: string) { + const defaultIndex = findTopLevelCharacter(value, "="); + return defaultIndex >= 0 ? value.slice(0, defaultIndex) : value; +} + +function findTopLevelCharacter(value: string, target: ":" | "=") { + let depth = 0; + for (let index = 0; index < value.length; index += 1) { + const character = value[index] ?? ""; + if (character === "{" || character === "[" || character === "(") depth += 1; + if (character === "}" || character === "]" || character === ")") depth -= 1; + if (character === target && depth === 0) return index; + } + return -1; +} + +function findMatchingParen(value: string, openIndex: number) { + if (openIndex < 0) return null; + let depth = 0; + for (let index = openIndex; index < value.length; index += 1) { + const character = value[index] ?? ""; + if (character === "(") depth += 1; + if (character === ")") { + depth -= 1; + if (depth === 0) return index; + } + } + return null; +} + +function findMatchingOpenParen(value: string, closeIndex: number) { + if (closeIndex < 0) return null; + let depth = 0; + for (let index = closeIndex; index >= 0; index -= 1) { + const character = value[index] ?? ""; + if (character === ")") depth += 1; + if (character === "(") { + depth -= 1; + if (depth === 0) return index; + } + } + return null; +} + +function findMatchingBrace(value: string, openIndex: number) { + if (openIndex < 0) return null; + let depth = 0; + for (let index = openIndex; index < value.length; index += 1) { + const character = value[index] ?? ""; + if (character === "{") depth += 1; + if (character === "}") { + depth -= 1; + if (depth === 0) return index; + } + } + return null; +} + +function findMatchingBracket(value: string, openIndex: number) { + if (openIndex < 0) return null; + let depth = 0; + for (let index = openIndex; index < value.length; index += 1) { + const character = value[index] ?? ""; + if (character === "[") depth += 1; + if (character === "]") { + depth -= 1; + if (depth === 0) return index; + } + } + return null; +} + +function doesLineStartMultilineParameterList(line: string) { + return ( + /\bfunction\b[^()]*\([^)]*$/.test(line) || + /=\s*(?:async\s*)?\([^)]*$/.test(line) || + /^\s*\([^)]*$/.test(line) || + doesLineStartMethodParameterList(line) + ); +} + +function doesLineStartMethodParameterList(line: string) { + const openIndex = line.lastIndexOf("("); + if (openIndex < 0 || line.slice(openIndex + 1).includes(")")) return false; + return findMethodSignatureStartIndex(line, openIndex) !== null; +} + +function isMultilineParameterListEnd(line: string) { + return /\)\s*(?::.*)?\s*(?:=>|\{)/.test(line); +} + +function isMultilineParameterListClosed(text: string) { + const openIndex = text.indexOf("("); + return openIndex >= 0 && findMatchingParen(text, openIndex) !== null; +} + +function isMultilineArrowExpressionBodyStart(line: string) { + return /\)\s*(?::.*)?\s*=>\s*$/.test(line); +} + +function findMultilineArrowExpressionBodyEndLineExclusive( + lines: readonly string[], + lineIndex: number, +) { + const line = lines[lineIndex] ?? ""; + if (!isMultilineArrowExpressionBodyStart(line)) return null; + let expressionStarted = false; + let depth = 0; + for (let index = lineIndex + 1; index < lines.length; index += 1) { + const trimmed = (lines[index] ?? "").trim(); + if (!trimmed) continue; + expressionStarted = true; + depth = Math.max(0, depth + countExpressionDelimiterDelta(trimmed)); + if (depth > 0 || isLineContinuingArrowExpression(trimmed)) continue; + return index + 1; + } + return expressionStarted ? lines.length : lineIndex + 1; +} + +function isLineContinuingArrowExpression(trimmedLine: string) { + return /(?:[?:.,]|&&|\|\||[+\-*/%&|^])$/.test(trimmedLine); +} + +function countExpressionDelimiterDelta(line: string) { + let delta = 0; + for (const character of line) { + if (character === "{" || character === "[" || character === "(") delta += 1; + if (character === "}" || character === "]" || character === ")") delta -= 1; + } + return delta; +} + +function isLocalDeclarationShadow(line: string, name: string, fsAliasNames: ReadonlySet) { + if (fsAliasNames.has(name)) return false; + return ( + isVariableDeclarationShadow(line, name) || + isDestructuredVariableDeclarationShadow(line, name) || + isFunctionDeclarationShadow(line, name) + ); +} + +function shouldPersistShadow( + line: string, + name: string, + depth: number, + depthAfterLine: number, + fsAliasNames: ReadonlySet, +) { + if ( + !isFunctionDeclarationShadow(line, name) && + isLocalDeclarationShadow(line, name, fsAliasNames) && + !isVarDeclarationShadow(line, name) && + !isVarDestructuredDeclarationShadow(line, name) && + isSameLineBlockScopedLocalShadow(line, name) + ) + return false; + if (isParameterShadowedOnLine(line, name) && depthAfterLine <= depth) return false; + return true; +} + +function isSameLineBlockScopedLocalShadow(line: string, name: string) { + const starts = [ + findVariableDeclarationShadowStartIndex(line, name), + findDestructuredVariableDeclarationShadowStartIndex(line, name), + ]; + return starts.some( + (start) => start >= 0 && findSameLineDeclarationScopeEnd(line, start) !== null, + ); +} + +function shadowDepthForLine( + line: string, + name: string, + depth: number, + depthAfterLine: number, + activeFunctionScopeDepths: readonly number[], + functionBodyOpenIndices: readonly number[], +) { + if (isFunctionDeclarationShadow(line, name)) return depth; + if (isVarDeclarationShadow(line, name) || isVarDestructuredDeclarationShadow(line, name)) { + return varShadowDepthForLine( + line, + name, + depth, + activeFunctionScopeDepths, + functionBodyOpenIndices, + ); + } + return depthAfterLine > depth ? depthAfterLine : depth; +} + +function varShadowDepthForLine( + line: string, + name: string, + depth: number, + activeFunctionScopeDepths: readonly number[], + functionBodyOpenIndices: readonly number[], +) { + const declarationStart = Math.max( + findVariableDeclarationShadowStartIndex(line, name, "var"), + findDestructuredVariableDeclarationShadowStartIndex(line, name, "var"), + ); + const sameLineFunctionScopeDepths = functionBodyOpenIndices + .filter((bodyOpenIndex) => bodyOpenIndex < declarationStart) + .map((bodyOpenIndex) => depthAfterColumn(line, bodyOpenIndex, depth)); + const functionScopeDepths = [...activeFunctionScopeDepths, ...sameLineFunctionScopeDepths]; + return functionScopeDepths.at(-1) ?? 0; +} + +function isVariableDeclarationShadow(line: string, name: string) { + return findVariableDeclarationShadowStartIndex(line, name) >= 0; +} + +function isDestructuredVariableDeclarationShadow(line: string, name: string) { + return findDestructuredVariableDeclarationShadowStartIndex(line, name) >= 0; +} + +function isFunctionDeclarationShadow(line: string, name: string) { + return findFunctionDeclarationShadowStartIndex(line, name) >= 0; +} + +function isCatchBindingShadow(line: string, name: string) { + return findCatchBindingShadowStartIndex(line, name) >= 0; +} + +function isVarDeclarationShadow(line: string, name: string) { + return findVariableDeclarationShadowStartIndex(line, name, "var") >= 0; +} + +function isVarDestructuredDeclarationShadow(line: string, name: string) { + return findDestructuredVariableDeclarationShadowStartIndex(line, name, "var") >= 0; +} + +function findVariableDeclarationShadowStartIndex( + line: string, + name: string, + kind?: "const" | "let" | "var", +) { + for (const declaration of line.matchAll(/\b(const|let|var)\s+/g)) { + const declarationKind = declaration[1]; + if (kind && declarationKind !== kind) continue; + const declarationStart = declaration.index; + const declaratorStart = declarationStart + declaration[0].length; + const declarationEnd = findSameLineStatementEnd(line, declaratorStart) ?? line.length; + const declarators = splitTopLevel(line.slice(declaratorStart, declarationEnd), ","); + let offset = declaratorStart; + for (const declarator of declarators) { + const binding = stripTopLevelDefault(declarator).trim(); + if (new RegExp(`^${escapeRegExp(name)}\\b`).test(binding)) { + const bindingOffset = declarator.indexOf(binding); + return offset + Math.max(0, bindingOffset); + } + offset += declarator.length + 1; + } + } + return -1; +} + +function findDestructuredVariableDeclarationShadowStartIndex( + line: string, + name: string, + kind?: "const" | "let" | "var", +) { + for (const declaration of line.matchAll(/\b(const|let|var)\s*[[{]/g)) { + const declarationKind = declaration[1]; + if (kind && declarationKind !== kind) continue; + const openIndex = findDestructuringOpenIndex(line, declaration.index); + const closeIndex = + line[openIndex] === "{" + ? findMatchingBrace(line, openIndex) + : findMatchingBracket(line, openIndex); + if (closeIndex === null) continue; + const boundNames = new Set(); + collectBindingNames(line.slice(openIndex, closeIndex + 1), boundNames); + if (boundNames.has(name)) return declaration.index; + } + return -1; +} + +function findDestructuringOpenIndex(line: string, declarationIndex: number) { + const objectIndex = line.indexOf("{", declarationIndex); + const arrayIndex = line.indexOf("[", declarationIndex); + if (objectIndex < 0) return arrayIndex; + if (arrayIndex < 0) return objectIndex; + return Math.min(objectIndex, arrayIndex); +} + +function findFunctionDeclarationShadowStartIndex(line: string, name: string) { + return new RegExp(`\\bfunction\\s+${escapeRegExp(name)}\\s*\\(`).exec(line)?.index ?? -1; +} + +function findCatchBindingShadowStartIndex(line: string, name: string) { + for (const match of line.matchAll(/\bcatch\s*\(/g)) { + const openIndex = line.indexOf("(", match.index); + const closeIndex = findMatchingParen(line, openIndex); + if (closeIndex === null) continue; + const boundNames = new Set(); + collectBindingNames(line.slice(openIndex + 1, closeIndex), boundNames); + if (boundNames.has(name)) return match.index; + } + return -1; +} + +function countBraceDelta(line: string) { + let delta = 0; + for (const character of line) { + if (character === "{") delta += 1; + if (character === "}") delta -= 1; + } + return delta; +} + +function depthBeforeLine(lines: readonly string[], lineIndex: number) { + let depth = 0; + for (let index = 0; index < lineIndex; index += 1) { + depth = Math.max(0, depth + countBraceDelta(lines[index] ?? "")); + } + return depth; +} + +function lineIndexForContentOffset(content: string, offset: number) { + let line = 0; + for (let index = 0; index < offset; index += 1) { + if (content[index] === "\n") line += 1; + } + return line; +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function buildPackageDryRunFilesystemEvidence( + findings: readonly PackageDryRunFilesystemFindingLike[], + options: BuildPackageDryRunFilesystemEvidenceOptions = {}, +): PackageDryRunFilesystemEvidence { + const maxEvidenceItems = normalizePositiveInteger( + options.maxEvidenceItems, + DEFAULT_MAX_EVIDENCE_ITEMS, + ); + const maxEvidenceChars = normalizePositiveInteger( + options.maxEvidenceChars, + DEFAULT_MAX_EVIDENCE_CHARS, + ); + + return { + rawFsUsage: buildEvidenceBucket( + findings, + PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.RAW_FS_USAGE, + maxEvidenceItems, + maxEvidenceChars, + ), + fsSafeUsage: buildEvidenceBucket( + findings, + PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.FS_SAFE_USAGE, + maxEvidenceItems, + maxEvidenceChars, + ), + }; +} + +function createPackageDryRunFilesystemEvidenceAccumulator(): PackageDryRunFilesystemEvidenceAccumulator { + return { + rawFsUsage: { + reasonCode: PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.RAW_FS_USAGE, + totalCount: 0, + findings: [], + }, + fsSafeUsage: { + reasonCode: PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.FS_SAFE_USAGE, + totalCount: 0, + findings: [], + }, + }; +} + +function recordPackageDryRunFilesystemFinding( + accumulator: PackageDryRunFilesystemEvidenceAccumulator, + finding: PackageDryRunFilesystemFindingLike, + maxEvidenceItems: number, +) { + const bucket = + finding.code === PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.RAW_FS_USAGE + ? accumulator.rawFsUsage + : finding.code === PACKAGE_DRY_RUN_FILESYSTEM_REASON_CODES.FS_SAFE_USAGE + ? accumulator.fsSafeUsage + : null; + if (bucket === null) return; + bucket.totalCount += 1; + insertBoundedFinding(bucket.findings, finding, maxEvidenceItems); +} + +function insertBoundedFinding( + findings: PackageDryRunFilesystemFindingLike[], + finding: PackageDryRunFilesystemFindingLike, + maxEvidenceItems: number, +) { + if (findings.length < maxEvidenceItems) { + findings.push(finding); + findings.sort(compareFindings); + return; + } + + const lastFinding = findings.at(-1); + if (lastFinding && compareFindings(finding, lastFinding) < 0) { + findings[findings.length - 1] = finding; + findings.sort(compareFindings); + } +} + +function buildPackageDryRunFilesystemEvidenceFromAccumulator( + accumulator: PackageDryRunFilesystemEvidenceAccumulator, + options: BuildPackageDryRunFilesystemEvidenceOptions = {}, +): PackageDryRunFilesystemEvidence { + const maxEvidenceChars = normalizePositiveInteger( + options.maxEvidenceChars, + DEFAULT_MAX_EVIDENCE_CHARS, + ); + return { + rawFsUsage: buildEvidenceBucketFromAccumulator(accumulator.rawFsUsage, maxEvidenceChars), + fsSafeUsage: buildEvidenceBucketFromAccumulator(accumulator.fsSafeUsage, maxEvidenceChars), + }; +} + +function buildEvidenceBucketFromAccumulator( + accumulator: PackageDryRunFilesystemBucketAccumulator, + maxEvidenceChars: number, +): PackageDryRunFilesystemEvidenceBucket { + const evidence = accumulator.findings.map((finding) => + toEvidenceItem(finding, accumulator.reasonCode, maxEvidenceChars), + ); + return { + reasonCode: accumulator.reasonCode, + totalCount: accumulator.totalCount, + returnedCount: evidence.length, + omittedCount: Math.max(0, accumulator.totalCount - evidence.length), + truncatedEvidenceCount: evidence.filter((item) => item.evidenceTruncated).length, + evidence, + }; +} + +function buildEvidenceBucket( + findings: readonly PackageDryRunFilesystemFindingLike[], + reasonCode: PackageDryRunFilesystemReasonCode, + maxEvidenceItems: number, + maxEvidenceChars: number, +): PackageDryRunFilesystemEvidenceBucket { + const matchingFindings = findings + .filter((finding) => finding.code === reasonCode) + .sort(compareFindings); + const evidence = matchingFindings + .slice(0, maxEvidenceItems) + .map((finding) => toEvidenceItem(finding, reasonCode, maxEvidenceChars)); + + return { + reasonCode, + totalCount: matchingFindings.length, + returnedCount: evidence.length, + omittedCount: Math.max(0, matchingFindings.length - evidence.length), + truncatedEvidenceCount: evidence.filter((item) => item.evidenceTruncated).length, + evidence, + }; +} + +function toEvidenceItem( + finding: PackageDryRunFilesystemFindingLike, + reasonCode: PackageDryRunFilesystemReasonCode, + maxEvidenceChars: number, +): PackageDryRunFilesystemEvidenceItem { + const truncated = truncateEvidence(finding.evidence, maxEvidenceChars); + return { + code: reasonCode, + severity: finding.severity, + file: finding.file, + line: finding.line, + message: finding.message, + evidence: truncated.value, + evidenceTruncated: truncated.truncated, + }; +} + +function truncateEvidence(evidence: string, maxEvidenceChars: number) { + if (evidence.length <= maxEvidenceChars) return { value: evidence, truncated: false }; + if (maxEvidenceChars <= ELLIPSIS.length) { + return { value: ELLIPSIS.slice(0, maxEvidenceChars), truncated: true }; + } + return { + value: `${evidence.slice(0, maxEvidenceChars - ELLIPSIS.length)}${ELLIPSIS}`, + truncated: true, + }; +} + +function normalizePositiveInteger(value: number | undefined, fallback: number) { + if (value === undefined) return fallback; + if (!Number.isInteger(value) || value < 1) return fallback; + return value; +} + +function compareFindings( + left: PackageDryRunFilesystemFindingLike, + right: PackageDryRunFilesystemFindingLike, +) { + return ( + left.file.localeCompare(right.file) || + left.line - right.line || + left.code.localeCompare(right.code) || + left.message.localeCompare(right.message) || + left.evidence.localeCompare(right.evidence) + ); +} diff --git a/convex/packageDryRunScans.test.ts b/convex/packageDryRunScans.test.ts new file mode 100644 index 0000000000..dca497fc4e --- /dev/null +++ b/convex/packageDryRunScans.test.ts @@ -0,0 +1,2482 @@ +/* @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { requireUser } from "./lib/access"; +import { runPackageDryRunFilesystemScan } from "./lib/packageDryRunFilesystemScan"; +import { + claimPackageDryRunScanResultsInternal, + completePackageDryRunScanResultInternal, + createPackageDryRunScanJob, + createPackageDryRunScanJobForUserInternal, + enqueuePackageDryRunScanJobTargetsInternal, + failPackageDryRunScanJobInternal, + failPackageDryRunScanResultInternal, + finalizePackageDryRunScanJobInternal, + getPackageDryRunScanInputInternal, + getPackageDryRunScanJobForUserInternal, + listPackageDryRunScanResultsForUserInternal, + processPackageDryRunScanJobBatchInternal, + prunePackageDryRunScansInternal, + skipPackageDryRunScanResultInternal, +} from "./packageDryRunScans"; + +vi.mock("./lib/access", () => ({ + requireUser: vi.fn(), + assertAdmin: (user: { role?: string }) => { + if (user.role !== "admin") throw new Error("Forbidden"); + }, +})); +vi.mock("./lib/packageDryRunFilesystemScan", () => ({ + runPackageDryRunFilesystemScan: vi.fn(), +})); + +type WrappedHandler = { + _handler: (ctx: unknown, args: TArgs) => Promise; +}; + +type PackageDryRunScanJob = { + _id: string; + scanner: string; + status: "queued" | "running" | "completed" | "failed"; + selector: + | { kind: "releaseIds"; releaseIds: string[] } + | { kind: "packageNames"; packageNames: string[] } + | { kind: "latestActive"; limit: number } + | { kind: "allActive" } + | { kind: "seededSample"; seed: string; limit: number; maxCandidates: number }; + requestedByUserId: string; + totalItems: number; + queuedItems: number; + runningItems: number; + completedItems: number; + failedItems: number; + skippedItems: number; + matchedItems: number; + cursor?: string | null; + targetSelectionDone?: boolean; + candidateLimitReached?: boolean; + staleRecheckAt?: number; + expiresAt: number; + createdAt: number; + updatedAt: number; + startedAt?: number; + completedAt?: number; + error?: string; +}; + +type PackageDryRunScanResult = { + _id: string; + jobId: string; + releaseId: string; + packageId: string; + status: "queued" | "running" | "completed" | "failed" | "skipped"; + packageName: string; + packageDisplayName: string; + version: string; + rawFsUsageCount: number; + fsSafeUsageCount: number; + findings: unknown[]; + errors: string[]; + claimToken?: string; + leaseExpiresAt?: number; + createdAt: number; + updatedAt: number; + startedAt?: number; + completedAt?: number; + result?: { status: "clean" | "suspicious" | "malicious"; summary: string }; + skippedReason?: string; + error?: string; +}; + +const createJobHandler = ( + createPackageDryRunScanJob as unknown as WrappedHandler< + { + selector: + | { kind: "releaseIds"; releaseIds: string[] } + | { kind: "packageNames"; packageNames: string[] } + | { kind: "latestActive"; limit: number } + | { kind: "allActive" } + | { kind: "seededSample"; seed: string; limit: number; maxCandidates: number }; + }, + { + jobId: string; + status: string; + totalItems: number; + targetSelectionDone: boolean; + candidateLimitReached?: boolean; + } + > +)._handler; +const createJobForUserHandler = ( + createPackageDryRunScanJobForUserInternal as unknown as WrappedHandler<{ + actorUserId: string; + selector: + | { kind: "releaseIds"; releaseIds: string[] } + | { kind: "packageNames"; packageNames: string[] } + | { kind: "latestActive"; limit: number } + | { kind: "allActive" } + | { kind: "seededSample"; seed: string; limit: number; maxCandidates: number }; + }> +)._handler; +const getJobForUserHandler = ( + getPackageDryRunScanJobForUserInternal as unknown as WrappedHandler<{ + actorUserId: string; + jobId: string; + }> +)._handler; +const processBatchHandler = ( + processPackageDryRunScanJobBatchInternal as unknown as WrappedHandler<{ + jobId: string; + batchSize?: number; + }> +)._handler; +const enqueueTargetsHandler = ( + enqueuePackageDryRunScanJobTargetsInternal as unknown as WrappedHandler<{ jobId: string }> +)._handler; +const failResultHandler = ( + failPackageDryRunScanResultInternal as unknown as WrappedHandler<{ + itemId: string; + claimToken: string; + error: string; + }> +)._handler; +const skipResultHandler = ( + skipPackageDryRunScanResultInternal as unknown as WrappedHandler<{ + itemId: string; + claimToken: string; + reason: string; + }> +)._handler; +const failJobHandler = ( + failPackageDryRunScanJobInternal as unknown as WrappedHandler<{ + jobId: string; + error: string; + }> +)._handler; +const completeResultHandler = ( + completePackageDryRunScanResultInternal as unknown as WrappedHandler<{ + itemId: string; + claimToken: string; + result: { + rawFsUsage: { + reasonCode: string; + totalCount: number; + returnedCount: number; + omittedCount: number; + truncatedEvidenceCount: number; + evidence: unknown[]; + }; + fsSafeUsage: { + reasonCode: string; + totalCount: number; + returnedCount: number; + omittedCount: number; + truncatedEvidenceCount: number; + evidence: unknown[]; + }; + }; + }> +)._handler; +const claimResultsHandler = ( + claimPackageDryRunScanResultsInternal as unknown as WrappedHandler< + { + jobId: string; + batchSize?: number; + }, + Array<{ itemId: string }> + > +)._handler; +const pruneJobsHandler = ( + prunePackageDryRunScansInternal as unknown as WrappedHandler<{ + jobBatchSize?: number; + resultBatchSize?: number; + }> +)._handler; +const getScanInputHandler = ( + getPackageDryRunScanInputInternal as unknown as WrappedHandler<{ releaseId: string }> +)._handler; +const finalizeJobHandler = ( + finalizePackageDryRunScanJobInternal as unknown as WrappedHandler<{ jobId: string }> +)._handler; +const listResultsHandler = ( + listPackageDryRunScanResultsForUserInternal as unknown as WrappedHandler< + { + actorUserId: string; + jobId: string; + cursor: string | null; + limit: number; + }, + { + items: Array<{ itemId: string; status: string }>; + } + > +)._handler; + +function chainEq(constraints: Record) { + return { + eq(field: string, value: unknown) { + constraints[field] = value; + return chainEq(constraints); + }, + lte(field: string, value: unknown) { + constraints[field] = value; + return chainEq(constraints); + }, + }; +} + +function matches(doc: Record, constraints: Record) { + return Object.entries(constraints).every(([key, value]) => { + if (key === "leaseExpiresAt" || key === "expiresAt") { + return typeof doc[key] === "number" && typeof value === "number" && doc[key] <= value; + } + return doc[key] === value; + }); +} + +function createDb() { + const now = 1_700_000_000_000; + const packages = new Map([ + [ + "packages:demo", + { + _id: "packages:demo", + name: "demo-plugin", + normalizedName: "demo-plugin", + displayName: "Demo Plugin", + summary: "A demo package", + family: "code-plugin", + latestReleaseId: "packageReleases:demo", + softDeletedAt: undefined, + }, + ], + ]); + const releases = new Map([ + [ + "packageReleases:demo", + { + _id: "packageReleases:demo", + packageId: "packages:demo", + version: "1.0.0", + files: [], + extractedPackageJson: { name: "demo-plugin" }, + extractedPluginManifest: { id: "demo-plugin" }, + normalizedBundleManifest: undefined, + source: { kind: "test" }, + softDeletedAt: undefined, + createdAt: now, + }, + ], + ]); + const jobs: PackageDryRunScanJob[] = []; + const items: PackageDryRunScanResult[] = []; + + const db = { + get: vi.fn(async (id: string) => { + if (id === "users:admin") return { _id: id, role: "admin" }; + if (id === "users:moderator") return { _id: id, role: "moderator" }; + if (id === "users:deactivated") { + return { _id: id, role: "admin", deactivatedAt: 1_700_000_000_000 }; + } + if (packages.has(id)) return packages.get(id); + if (releases.has(id)) return releases.get(id); + return jobs.find((job) => job._id === id) ?? items.find((item) => item._id === id) ?? null; + }), + insert: vi.fn(async (table: string, doc: Record) => { + if (table === "packageDryRunScanJobs") { + const job = { _id: `packageDryRunScanJobs:${jobs.length + 1}`, ...doc }; + jobs.push(job as PackageDryRunScanJob); + return job._id; + } + if (table === "packageDryRunScanResults") { + const item = { _id: `packageDryRunScanResults:${items.length + 1}`, ...doc }; + items.push(item as PackageDryRunScanResult); + return item._id; + } + throw new Error(`unexpected insert ${table}`); + }), + patch: vi.fn(async (id: string, patch: Record) => { + const job = jobs.find((candidate) => candidate._id === id); + if (job) { + Object.assign(job, patch); + return; + } + const item = items.find((candidate) => candidate._id === id); + if (item) { + Object.assign(item, patch); + return; + } + throw new Error(`unexpected patch ${id}`); + }), + delete: vi.fn(async (id: string) => { + const jobIndex = jobs.findIndex((candidate) => candidate._id === id); + if (jobIndex >= 0) { + jobs.splice(jobIndex, 1); + return; + } + const itemIndex = items.findIndex((candidate) => candidate._id === id); + if (itemIndex >= 0) { + items.splice(itemIndex, 1); + return; + } + throw new Error(`unexpected delete ${id}`); + }), + query: vi.fn((table: string) => { + if (table === "packageDryRunScanJobs") { + return { + withIndex: (_name: string, build: (q: ReturnType) => unknown) => { + const constraints: Record = {}; + build(chainEq(constraints)); + return { + take: async (limit: number) => + jobs + .filter((job) => { + const expiresAt = constraints.expiresAt; + const expiresAtMatches = + typeof expiresAt === "number" ? job.expiresAt <= expiresAt : true; + const statusMatches = + typeof constraints.status === "string" + ? job.status === constraints.status + : true; + return expiresAtMatches && statusMatches; + }) + .slice(0, limit), + }; + }, + }; + } + if (table === "packageDryRunScanResults") { + return { + withIndex: (name: string, build: (q: ReturnType) => unknown) => { + const constraints: Record = {}; + build(chainEq(constraints)); + const matched = items + .filter((item) => matches(item as unknown as Record, constraints)) + .sort((left, right) => { + if (name !== "by_job_status_lease") return 0; + const leftLease = left.leaseExpiresAt ?? Number.NEGATIVE_INFINITY; + const rightLease = right.leaseExpiresAt ?? Number.NEGATIVE_INFINITY; + return leftLease - rightLease; + }); + return { + take: async (limit: number) => matched.slice(0, limit), + unique: async () => { + if (matched.length > 1) throw new Error("expected unique result"); + return matched[0] ?? null; + }, + paginate: async ({ + cursor, + numItems, + }: { + cursor: string | null; + numItems: number; + }) => { + const start = cursor ? Number.parseInt(cursor, 10) : 0; + const page = matched.slice(start, start + numItems); + const next = start + page.length; + return { + page, + isDone: next >= matched.length, + continueCursor: next >= matched.length ? "" : String(next), + }; + }, + order: () => ({ + take: async (limit: number) => matched.slice(0, limit), + }), + }; + }, + }; + } + if (table === "packageReleases") { + const orderedReleases = () => + [...releases.values()].sort( + (left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0), + ); + return { + withIndex: () => ({ + order: () => ({ + take: async (limit: number) => orderedReleases().slice(0, limit), + paginate: async ({ + cursor, + numItems, + }: { + cursor: string | null; + numItems: number; + }) => { + const all = orderedReleases(); + const start = cursor ? Number.parseInt(cursor, 10) : 0; + const page = all.slice(start, start + numItems); + const next = start + page.length; + return { + page, + isDone: next >= all.length, + continueCursor: next >= all.length ? null : String(next), + }; + }, + }), + }), + }; + } + if (table === "packages") { + return { + withIndex: (_name: string, build?: (q: ReturnType) => unknown) => ({ + unique: async () => { + const constraints: Record = {}; + build?.(chainEq(constraints)); + return ( + [...packages.values()].find((pkg) => + matches(pkg as unknown as Record, constraints), + ) ?? null + ); + }, + }), + }; + } + throw new Error(`unexpected table ${table}`); + }), + normalizeId: vi.fn((table: string, id: string) => (id.startsWith(`${table}:`) ? id : null)), + }; + + return { db, jobs, items, packages, releases }; +} + +beforeEach(() => { + vi.mocked(requireUser).mockReset(); + vi.mocked(runPackageDryRunFilesystemScan).mockReset(); + vi.mocked(requireUser).mockResolvedValue({ + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); +}); + +describe("package dry-run scan jobs", () => { + it("lets admins create a queued dry-run scan job for explicit releases", async () => { + const { db, jobs, items } = createDb(); + const scheduler = { runAfter: vi.fn(async (_delay: number, ..._args: unknown[]) => undefined) }; + + const result = await createJobHandler({ db, scheduler } as never, { + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + }); + + expect(result).toEqual({ + jobId: "packageDryRunScanJobs:1", + status: "queued", + totalItems: 1, + targetSelectionDone: true, + }); + expect(jobs[0]).toMatchObject({ + status: "queued", + requestedByUserId: "users:admin", + totalItems: 1, + queuedItems: 1, + targetSelectionDone: true, + }); + expect(items[0]).toMatchObject({ + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + status: "queued", + packageName: "demo-plugin", + version: "1.0.0", + }); + expect(db.patch).not.toHaveBeenCalledWith( + "packageReleases:demo", + expect.objectContaining({ staticScan: expect.anything() }), + ); + expect(db.patch).not.toHaveBeenCalledWith( + "packages:demo", + expect.objectContaining({ scanStatus: expect.anything() }), + ); + expect(scheduler.runAfter).toHaveBeenCalledTimes(1); + }); + + it("rejects explicit release selections when any requested release is unresolved", async () => { + const { db, items } = createDb(); + const scheduler = { runAfter: vi.fn(async () => undefined) }; + + await expect( + createJobHandler({ db, scheduler } as never, { + selector: { + kind: "releaseIds", + releaseIds: ["packageReleases:demo", "packageReleases:missing"], + }, + }), + ).rejects.toThrow( + "Dry-run scan selector could not resolve releaseIds: packageReleases:missing", + ); + expect(items).toHaveLength(0); + expect(scheduler.runAfter).not.toHaveBeenCalled(); + }); + + it("forbids non-admin actors on internal dry-run scan entrypoints", async () => { + const { db } = createDb(); + const scheduler = { runAfter: vi.fn(async () => undefined) }; + + await expect( + createJobForUserHandler({ db, scheduler } as never, { + actorUserId: "users:moderator", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + }), + ).rejects.toThrow("Forbidden"); + await expect( + getJobForUserHandler({ db } as never, { + actorUserId: "users:moderator", + jobId: "packageDryRunScanJobs:1", + }), + ).rejects.toThrow("Forbidden"); + await expect( + listResultsHandler({ db } as never, { + actorUserId: "users:deactivated", + jobId: "packageDryRunScanJobs:1", + cursor: null, + limit: 10, + }), + ).rejects.toThrow("Unauthorized"); + }); + + it("queues latest active package releases by release creation order with a bounded limit", async () => { + const { db, items } = createDb(); + const scheduler = { runAfter: vi.fn(async (_delay: number, ..._args: unknown[]) => undefined) }; + + const result = await createJobHandler({ db, scheduler } as never, { + selector: { kind: "latestActive", limit: 1 }, + }); + + expect(result).toMatchObject({ + jobId: "packageDryRunScanJobs:1", + status: "queued", + totalItems: 1, + }); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + releaseId: "packageReleases:demo", + packageId: "packages:demo", + status: "queued", + }); + expect(db.query).toHaveBeenCalledWith("packageReleases"); + }); + + it("skips older active package releases in latest-active and seeded selectors", async () => { + const { db, packages, releases, items } = createDb(); + const scheduler = { runAfter: vi.fn(async (_delay: number, ..._args: unknown[]) => undefined) }; + packages.set("packages:versioned", { + _id: "packages:versioned", + name: "versioned-plugin", + normalizedName: "versioned-plugin", + displayName: "Versioned Plugin", + summary: "A plugin package", + family: "code-plugin", + latestReleaseId: "packageReleases:versioned-new", + softDeletedAt: undefined, + }); + releases.set("packageReleases:versioned-old", { + _id: "packageReleases:versioned-old", + packageId: "packages:versioned", + version: "1.0.0", + files: [], + extractedPackageJson: { name: "versioned-plugin" }, + extractedPluginManifest: { id: "versioned-plugin" }, + normalizedBundleManifest: undefined, + source: { kind: "test" }, + softDeletedAt: undefined, + createdAt: 1_700_000_020_000, + }); + releases.set("packageReleases:versioned-new", { + _id: "packageReleases:versioned-new", + packageId: "packages:versioned", + version: "2.0.0", + files: [], + extractedPackageJson: { name: "versioned-plugin" }, + extractedPluginManifest: { id: "versioned-plugin" }, + normalizedBundleManifest: undefined, + source: { kind: "test" }, + softDeletedAt: undefined, + createdAt: 1_700_000_000_050, + }); + + await createJobHandler({ db, scheduler } as never, { + selector: { kind: "latestActive", limit: 2 }, + }); + await createJobHandler({ db, scheduler } as never, { + selector: { kind: "seededSample", seed: "fs-safe-v1", limit: 2, maxCandidates: 10 }, + }); + + expect(items.map((item) => item.releaseId)).not.toContain("packageReleases:versioned-old"); + expect(items.map((item) => item.releaseId)).toContain("packageReleases:versioned-new"); + }); + + it("selects latest active release ties deterministically by release id", async () => { + function addReleaseSet(state: ReturnType, releaseIds: readonly string[]) { + for (const releaseId of releaseIds) { + const suffix = releaseId.split(":").at(-1); + const packageId = `packages:${suffix}`; + state.packages.set(packageId, { + _id: packageId, + name: `plugin-${suffix}`, + normalizedName: `plugin-${suffix}`, + displayName: `Plugin ${suffix}`, + summary: "A plugin package", + family: "code-plugin", + latestReleaseId: releaseId, + softDeletedAt: undefined, + }); + state.releases.set(releaseId, { + _id: releaseId, + packageId, + version: "1.0.0", + files: [], + extractedPackageJson: { name: `plugin-${suffix}` }, + extractedPluginManifest: { id: `plugin-${suffix}` }, + normalizedBundleManifest: undefined, + source: { kind: "test" }, + softDeletedAt: undefined, + createdAt: 1_700_000_010_000, + }); + } + } + + const first = createDb(); + const second = createDb(); + addReleaseSet(first, ["packageReleases:c", "packageReleases:a", "packageReleases:b"]); + addReleaseSet(second, ["packageReleases:b", "packageReleases:c", "packageReleases:a"]); + const scheduler = { runAfter: vi.fn(async () => undefined) }; + + await createJobHandler({ db: first.db, scheduler } as never, { + selector: { kind: "latestActive", limit: 2 }, + }); + await createJobHandler({ db: second.db, scheduler } as never, { + selector: { kind: "latestActive", limit: 2 }, + }); + + expect(first.items.map((item) => item.releaseId)).toEqual([ + "packageReleases:a", + "packageReleases:b", + ]); + expect(second.items.map((item) => item.releaseId)).toEqual([ + "packageReleases:a", + "packageReleases:b", + ]); + }); + + it("caps latest active selection pages inside job creation", async () => { + const { db, packages, releases } = createDb(); + const scheduler = { runAfter: vi.fn(async () => undefined) }; + packages.set("packages:skill", { + _id: "packages:skill", + name: "demo-skill", + normalizedName: "demo-skill", + displayName: "Demo Skill", + summary: "A skill package", + family: "skill", + latestReleaseId: "packageReleases:skill-0", + softDeletedAt: undefined, + }); + for (let index = 0; index < 1_001; index += 1) { + releases.set(`packageReleases:skill-${index}`, { + _id: `packageReleases:skill-${index}`, + packageId: "packages:skill", + version: `1.0.${index}`, + files: [], + extractedPackageJson: { name: "demo-skill" }, + extractedPluginManifest: { id: "demo-skill" }, + normalizedBundleManifest: undefined, + source: { kind: "test" }, + softDeletedAt: undefined, + createdAt: 1_700_000_010_000 - index, + }); + } + + await expect( + createJobHandler({ db, scheduler } as never, { + selector: { kind: "latestActive", limit: 1 }, + }), + ).rejects.toThrow( + "Dry-run scan selector reached selection scan limit before collecting requested releases", + ); + expect(db.get).toHaveBeenCalledWith("packages:skill"); + expect(db.get).not.toHaveBeenCalledWith("packageReleases:skill-1000"); + expect(scheduler.runAfter).not.toHaveBeenCalled(); + }); + + it("accepts latest active selection when the capped page resolves the boundary", async () => { + const { db, packages, releases, items } = createDb(); + const scheduler = { runAfter: vi.fn(async () => undefined) }; + packages.set("packages:skill", { + _id: "packages:skill", + name: "demo-skill", + normalizedName: "demo-skill", + displayName: "Demo Skill", + summary: "A skill package", + family: "skill", + latestReleaseId: "packageReleases:skill-0", + softDeletedAt: undefined, + }); + for (const index of [ + ...Array.from({ length: 998 }, (_, value) => value), + ...Array.from({ length: 102 }, (_, value) => value + 1_000), + ]) { + releases.set(`packageReleases:skill-${index}`, { + _id: `packageReleases:skill-${index}`, + packageId: "packages:skill", + version: `1.0.${index}`, + files: [], + extractedPackageJson: { name: "demo-skill" }, + extractedPluginManifest: { id: "demo-skill" }, + normalizedBundleManifest: undefined, + source: { kind: "test" }, + softDeletedAt: undefined, + createdAt: 1_700_000_010_000 - index, + }); + } + packages.set("packages:boundary-a", { + _id: "packages:boundary-a", + name: "boundary-a", + normalizedName: "boundary-a", + displayName: "Boundary A", + summary: "A plugin package", + family: "code-plugin", + latestReleaseId: "packageReleases:boundary-a", + softDeletedAt: undefined, + }); + packages.set("packages:boundary-b", { + _id: "packages:boundary-b", + name: "boundary-b", + normalizedName: "boundary-b", + displayName: "Boundary B", + summary: "A plugin package", + family: "code-plugin", + latestReleaseId: "packageReleases:boundary-b", + softDeletedAt: undefined, + }); + releases.set("packageReleases:boundary-a", { + _id: "packageReleases:boundary-a", + packageId: "packages:boundary-a", + version: "1.0.0", + files: [], + extractedPackageJson: { name: "boundary-a" }, + extractedPluginManifest: { id: "boundary-a" }, + normalizedBundleManifest: undefined, + source: { kind: "test" }, + softDeletedAt: undefined, + createdAt: 1_700_000_010_000 - 998, + }); + releases.set("packageReleases:boundary-b", { + _id: "packageReleases:boundary-b", + packageId: "packages:boundary-b", + version: "1.0.0", + files: [], + extractedPackageJson: { name: "boundary-b" }, + extractedPluginManifest: { id: "boundary-b" }, + normalizedBundleManifest: undefined, + source: { kind: "test" }, + softDeletedAt: undefined, + createdAt: 1_700_000_010_000 - 999, + }); + + const result = await createJobHandler({ db, scheduler } as never, { + selector: { kind: "latestActive", limit: 1 }, + }); + + expect(result).toMatchObject({ totalItems: 1 }); + expect(items.map((item) => item.releaseId)).toEqual(["packageReleases:boundary-a"]); + expect(db.get).not.toHaveBeenCalledWith("packageReleases:boundary-a"); + expect(scheduler.runAfter).toHaveBeenCalledTimes(1); + }); + + it("rejects latest active selection when a capped tie boundary is unresolved", async () => { + const { db, packages, releases } = createDb(); + const scheduler = { runAfter: vi.fn(async () => undefined) }; + for (let index = 0; index < 1_001; index += 1) { + const releaseId = `packageReleases:tie-${index}`; + const packageId = `packages:tie-${index}`; + packages.set(packageId, { + _id: packageId, + name: `tie-plugin-${index}`, + normalizedName: `tie-plugin-${index}`, + displayName: `Tie Plugin ${index}`, + summary: "A plugin package", + family: "code-plugin", + latestReleaseId: releaseId, + softDeletedAt: undefined, + }); + releases.set(releaseId, { + _id: releaseId, + packageId, + version: "1.0.0", + files: [], + extractedPackageJson: { name: `tie-plugin-${index}` }, + extractedPluginManifest: { id: `tie-plugin-${index}` }, + normalizedBundleManifest: undefined, + source: { kind: "test" }, + softDeletedAt: undefined, + createdAt: 1_700_000_010_000, + }); + } + + await expect( + createJobHandler({ db, scheduler } as never, { + selector: { kind: "latestActive", limit: 2 }, + }), + ).rejects.toThrow( + "Dry-run scan selector reached selection scan limit before resolving release ordering", + ); + expect(scheduler.runAfter).not.toHaveBeenCalled(); + }); + + it("reconciles outstanding counters when a job-level failure makes the job terminal", async () => { + const { db, jobs, items } = createDb(); + jobs.push({ + _id: "packageDryRunScanJobs:1", + scanner: "filesystem-safety-v1", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + status: "running", + requestedByUserId: "users:admin", + totalItems: 2, + queuedItems: 1, + runningItems: 1, + completedItems: 0, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + targetSelectionDone: true, + expiresAt: 1_700_086_400_000, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + items.push( + { + _id: "packageDryRunScanResults:queued", + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "queued", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }, + { + _id: "packageDryRunScanResults:running", + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo-2", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.1", + status: "running", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + claimToken: "claim", + leaseExpiresAt: 1_700_000_600_000, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }, + ); + + await failJobHandler({ db } as never, { + jobId: "packageDryRunScanJobs:1", + error: "worker unavailable", + }); + + expect(jobs[0]).toMatchObject({ + status: "failed", + queuedItems: 0, + runningItems: 0, + failedItems: 2, + error: "worker unavailable", + }); + expect(jobs[0]?.expiresAt).toBeGreaterThan(1_700_086_400_000); + + const job = await getJobForUserHandler({ db } as never, { + actorUserId: "users:admin", + jobId: "packageDryRunScanJobs:1", + }); + expect(job).toMatchObject({ + status: "failed", + queuedItems: 0, + runningItems: 0, + failedItems: 2, + }); + + const results = await listResultsHandler({ db } as never, { + actorUserId: "users:admin", + jobId: "packageDryRunScanJobs:1", + cursor: null, + limit: 10, + }); + expect(results.items).toEqual([ + expect.objectContaining({ itemId: "packageDryRunScanResults:queued", status: "failed" }), + expect.objectContaining({ itemId: "packageDryRunScanResults:running", status: "failed" }), + ]); + }); + + it("bounds persisted dry-run scan error messages", async () => { + const { db, jobs, items } = createDb(); + const longError = "x".repeat(2_000); + jobs.push({ + _id: "packageDryRunScanJobs:1", + scanner: "filesystem-safety-v1", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + status: "running", + requestedByUserId: "users:admin", + totalItems: 1, + queuedItems: 0, + runningItems: 1, + completedItems: 0, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + targetSelectionDone: true, + expiresAt: 1_700_086_400_000, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + items.push({ + _id: "packageDryRunScanResults:1", + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "running", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + claimToken: "claim", + leaseExpiresAt: 1_700_000_600_000, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + + await failResultHandler({ db } as never, { + itemId: "packageDryRunScanResults:1", + claimToken: "claim", + error: longError, + }); + await failJobHandler({ db } as never, { + jobId: "packageDryRunScanJobs:1", + error: longError, + }); + + expect(items[0]?.errors[0]).toHaveLength(1_024); + expect(items[0]?.errors[0]).toMatch(/\.\.\.$/); + expect(jobs[0]?.error).toHaveLength(1_024); + expect(jobs[0]?.error).toMatch(/\.\.\.$/); + }); + + it("rejects direct Convex dry-run limits above the API maximum", async () => { + const { db } = createDb(); + const scheduler = { runAfter: vi.fn(async () => undefined) }; + + await expect( + createJobHandler({ db, scheduler } as never, { + selector: { kind: "latestActive", limit: 201 }, + }), + ).rejects.toThrow("limit must be at most 200"); + await expect( + createJobHandler({ db, scheduler } as never, { + selector: { kind: "seededSample", seed: "fs-safe-v1", limit: 1, maxCandidates: 1_001 }, + }), + ).rejects.toThrow("maxCandidates must be at most 1000"); + await expect( + createJobHandler({ db, scheduler } as never, { + selector: { kind: "seededSample", seed: "x".repeat(129), limit: 1, maxCandidates: 1 }, + }), + ).rejects.toThrow("seed must be at most 128 characters"); + }); + + it("rejects seeded sample candidate pools smaller than the requested limit", async () => { + const { db } = createDb(); + const scheduler = { runAfter: vi.fn(async () => undefined) }; + + await expect( + createJobHandler({ db, scheduler } as never, { + selector: { kind: "seededSample", seed: "fs-safe-v1", limit: 20, maxCandidates: 10 }, + }), + ).rejects.toThrow("maxCandidates must be greater than or equal to limit"); + }); + + it("queues latest releases for explicit package names", async () => { + const { db, items } = createDb(); + const scheduler = { runAfter: vi.fn(async (_delay: number, ..._args: unknown[]) => undefined) }; + + const result = await createJobHandler({ db, scheduler } as never, { + selector: { kind: "packageNames", packageNames: ["demo-plugin"] }, + }); + + expect(result).toMatchObject({ + jobId: "packageDryRunScanJobs:1", + status: "queued", + totalItems: 1, + }); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + releaseId: "packageReleases:demo", + packageName: "demo-plugin", + version: "1.0.0", + }); + }); + + it("rejects explicit package selections when any requested package is unresolved", async () => { + const { db, items } = createDb(); + const scheduler = { runAfter: vi.fn(async () => undefined) }; + + await expect( + createJobHandler({ db, scheduler } as never, { + selector: { kind: "packageNames", packageNames: ["demo-plugin", "missing-plugin"] }, + }), + ).rejects.toThrow("Dry-run scan selector could not resolve packageNames: missing-plugin"); + expect(items).toHaveLength(0); + expect(scheduler.runAfter).not.toHaveBeenCalled(); + }); + + it("reports pending target selection when creating all-active jobs", async () => { + const { db, jobs, items } = createDb(); + const scheduler = { runAfter: vi.fn(async (_delay: number, ..._args: unknown[]) => undefined) }; + + const result = await createJobHandler({ db, scheduler } as never, { + selector: { kind: "allActive" }, + }); + + expect(result).toEqual({ + jobId: "packageDryRunScanJobs:1", + status: "queued", + totalItems: 0, + targetSelectionDone: false, + }); + expect(jobs[0]).toMatchObject({ + selector: { kind: "allActive" }, + targetSelectionDone: false, + totalItems: 0, + queuedItems: 0, + }); + expect(items).toHaveLength(0); + expect(scheduler.runAfter).toHaveBeenCalledTimes(1); + }); + + it("keeps failed all-active result exports partial when target selection did not finish", async () => { + const { db, jobs } = createDb(); + jobs.push({ + _id: "packageDryRunScanJobs:1", + scanner: "filesystem-safety-v1", + selector: { kind: "allActive" }, + status: "failed", + requestedByUserId: "users:admin", + totalItems: 0, + queuedItems: 0, + runningItems: 0, + completedItems: 0, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + targetSelectionDone: false, + error: "target selection failed", + expiresAt: 1_700_086_400_000, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_100, + completedAt: 1_700_000_000_100, + }); + + const listed = await listResultsHandler({ db } as never, { + actorUserId: "users:admin", + jobId: "packageDryRunScanJobs:1", + cursor: null, + limit: 10, + }); + + expect(listed).toMatchObject({ + jobStatus: "failed", + jobDone: true, + partial: true, + done: true, + nextCursor: null, + items: [], + }); + }); + + it("selects seeded samples repeatably and reports candidate pool truncation", async () => { + const first = createDb(); + const second = createDb(); + for (const state of [first, second]) { + state.packages.set("packages:other", { + _id: "packages:other", + name: "other-plugin", + normalizedName: "other-plugin", + displayName: "Other Plugin", + summary: "Another package", + family: "code-plugin", + latestReleaseId: "packageReleases:other", + softDeletedAt: undefined, + }); + state.releases.set("packageReleases:other", { + _id: "packageReleases:other", + packageId: "packages:other", + version: "1.0.0", + files: [], + extractedPackageJson: { name: "other-plugin" }, + extractedPluginManifest: { id: "other-plugin" }, + normalizedBundleManifest: undefined, + source: { kind: "test" }, + softDeletedAt: undefined, + createdAt: 1_700_000_000_100, + }); + state.packages.set("packages:third", { + _id: "packages:third", + name: "third-plugin", + normalizedName: "third-plugin", + displayName: "Third Plugin", + summary: "A third package", + family: "code-plugin", + latestReleaseId: "packageReleases:third", + softDeletedAt: undefined, + }); + state.releases.set("packageReleases:third", { + _id: "packageReleases:third", + packageId: "packages:third", + version: "1.0.0", + files: [], + extractedPackageJson: { name: "third-plugin" }, + extractedPluginManifest: { id: "third-plugin" }, + normalizedBundleManifest: undefined, + source: { kind: "test" }, + softDeletedAt: undefined, + createdAt: 1_700_000_000_200, + }); + } + + const scheduler = { runAfter: vi.fn(async (_delay: number, ..._args: unknown[]) => undefined) }; + const firstResult = await createJobHandler({ db: first.db, scheduler } as never, { + selector: { kind: "seededSample", seed: "fs-safe-v1", limit: 1, maxCandidates: 2 }, + }); + const secondResult = await createJobHandler({ db: second.db, scheduler } as never, { + selector: { kind: "seededSample", seed: "fs-safe-v1", limit: 1, maxCandidates: 2 }, + }); + + expect(firstResult).toMatchObject({ + totalItems: 1, + candidateLimitReached: true, + }); + expect(secondResult).toMatchObject({ + totalItems: 1, + candidateLimitReached: true, + }); + expect(first.items.map((item) => item.releaseId)).toEqual( + second.items.map((item) => item.releaseId), + ); + }); + + it("rejects seeded samples when the max-candidate boundary is unresolved", async () => { + const { db, packages, releases } = createDb(); + const scheduler = { runAfter: vi.fn(async () => undefined) }; + for (let index = 0; index < 1_101; index += 1) { + const releaseId = `packageReleases:sample-tie-${index}`; + const packageId = `packages:sample-tie-${index}`; + packages.set(packageId, { + _id: packageId, + name: `sample-tie-plugin-${index}`, + normalizedName: `sample-tie-plugin-${index}`, + displayName: `Sample Tie Plugin ${index}`, + summary: "A plugin package", + family: "code-plugin", + latestReleaseId: releaseId, + softDeletedAt: undefined, + }); + releases.set(releaseId, { + _id: releaseId, + packageId, + version: "1.0.0", + files: [], + extractedPackageJson: { name: `sample-tie-plugin-${index}` }, + extractedPluginManifest: { id: `sample-tie-plugin-${index}` }, + normalizedBundleManifest: undefined, + source: { kind: "test" }, + softDeletedAt: undefined, + createdAt: 1_700_000_010_000, + }); + } + + await expect( + createJobHandler({ db, scheduler } as never, { + selector: { kind: "seededSample", seed: "fs-safe-v1", limit: 1, maxCandidates: 1_000 }, + }), + ).rejects.toThrow( + "Dry-run scan selector reached selection scan limit before resolving release ordering", + ); + expect(scheduler.runAfter).not.toHaveBeenCalled(); + }); + + it("rejects seeded samples when the candidate pool is truncated before max candidates", async () => { + const { db, packages, releases } = createDb(); + const scheduler = { runAfter: vi.fn(async () => undefined) }; + packages.set("packages:eligible", { + _id: "packages:eligible", + name: "eligible-plugin", + normalizedName: "eligible-plugin", + displayName: "Eligible Plugin", + summary: "A plugin package", + family: "code-plugin", + latestReleaseId: "packageReleases:eligible", + softDeletedAt: undefined, + }); + releases.set("packageReleases:eligible", { + _id: "packageReleases:eligible", + packageId: "packages:eligible", + version: "1.0.0", + files: [], + extractedPackageJson: { name: "eligible-plugin" }, + extractedPluginManifest: { id: "eligible-plugin" }, + normalizedBundleManifest: undefined, + source: { kind: "test" }, + softDeletedAt: undefined, + createdAt: 1_700_000_020_000, + }); + packages.set("packages:skill", { + _id: "packages:skill", + name: "sample-skill", + normalizedName: "sample-skill", + displayName: "Sample Skill", + summary: "A skill package", + family: "skill", + latestReleaseId: "packageReleases:skill-0", + softDeletedAt: undefined, + }); + for (let index = 0; index < 1_101; index += 1) { + releases.set(`packageReleases:skill-${index}`, { + _id: `packageReleases:skill-${index}`, + packageId: "packages:skill", + version: `1.0.${index}`, + files: [], + extractedPackageJson: { name: "sample-skill" }, + extractedPluginManifest: { id: "sample-skill" }, + normalizedBundleManifest: undefined, + source: { kind: "test" }, + softDeletedAt: undefined, + createdAt: 1_700_000_019_000 - index, + }); + } + + await expect( + createJobHandler({ db, scheduler } as never, { + selector: { kind: "seededSample", seed: "fs-safe-v1", limit: 1, maxCandidates: 1_000 }, + }), + ).rejects.toThrow( + "Dry-run scan selector reached selection scan limit before resolving release ordering", + ); + expect(scheduler.runAfter).not.toHaveBeenCalled(); + }); + + it("enqueues all-active scan targets in a bounded page", async () => { + const { db, jobs, items } = createDb(); + jobs.push({ + _id: "packageDryRunScanJobs:1", + scanner: "filesystem-safety-v1", + selector: { kind: "allActive" }, + status: "queued", + requestedByUserId: "users:admin", + totalItems: 0, + queuedItems: 0, + runningItems: 0, + completedItems: 0, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + cursor: null, + targetSelectionDone: false, + expiresAt: 1_700_086_400_000, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + + const result = await enqueueTargetsHandler({ db } as never, { + jobId: "packageDryRunScanJobs:1", + }); + + expect(result).toEqual({ enqueued: 1, done: true, advanced: true }); + expect(jobs[0]).toMatchObject({ + totalItems: 1, + queuedItems: 1, + targetSelectionDone: true, + }); + expect(items[0]).toMatchObject({ + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + status: "queued", + }); + }); + + it("does not enqueue duplicate all-active targets for the same job and release", async () => { + const { db, jobs, items } = createDb(); + jobs.push({ + _id: "packageDryRunScanJobs:1", + scanner: "filesystem-safety-v1", + selector: { kind: "allActive" }, + status: "queued", + requestedByUserId: "users:admin", + totalItems: 1, + queuedItems: 1, + runningItems: 0, + completedItems: 0, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + cursor: null, + targetSelectionDone: false, + expiresAt: 1_700_086_400_000, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + items.push({ + _id: "packageDryRunScanResults:1", + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "queued", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + + const result = await enqueueTargetsHandler({ db } as never, { + jobId: "packageDryRunScanJobs:1", + }); + + expect(result).toEqual({ enqueued: 0, done: true, advanced: true }); + expect(items).toHaveLength(1); + expect(jobs[0]).toMatchObject({ + totalItems: 1, + queuedItems: 1, + targetSelectionDone: true, + }); + }); + + it("creates, processes, and lists an all-active dry-run scan through the job handlers", async () => { + vi.mocked(runPackageDryRunFilesystemScan).mockResolvedValue({ + rawFsUsage: { + reasonCode: "info.filesystem.raw_fs_api_usage", + totalCount: 1, + returnedCount: 1, + omittedCount: 0, + truncatedEvidenceCount: 0, + evidence: [ + { + code: "info.filesystem.raw_fs_api_usage", + severity: "info", + file: "dist/index.js", + line: 1, + message: "Raw Node filesystem API usage detected.", + evidence: "import fs from 'node:fs';", + evidenceTruncated: false, + }, + ], + }, + fsSafeUsage: { + reasonCode: "info.filesystem.fs_safe_usage", + totalCount: 0, + returnedCount: 0, + omittedCount: 0, + truncatedEvidenceCount: 0, + evidence: [], + }, + }); + const { db, jobs, items } = createDb(); + const scheduler = { runAfter: vi.fn(async () => undefined) }; + + const created = await createJobHandler({ db, scheduler } as never, { + selector: { kind: "allActive" }, + }); + + let jobOnlyMutationCalls = 0; + const runMutation = vi.fn(async (_ref: unknown, args: Record) => { + if (args.jobId === created.jobId && Object.keys(args).length === 1) { + jobOnlyMutationCalls += 1; + if (jobOnlyMutationCalls === 1) { + return await enqueueTargetsHandler({ db } as never, { jobId: created.jobId }); + } + return await finalizeJobHandler({ db } as never, { jobId: created.jobId }); + } + if (args.jobId === created.jobId && args.batchSize === 1) { + return await claimResultsHandler({ db, scheduler } as never, { + jobId: created.jobId, + batchSize: 1, + }); + } + if (typeof args.itemId === "string" && "result" in args) { + return await completeResultHandler({ db } as never, args as never); + } + throw new Error(`unexpected mutation ${JSON.stringify(args)}`); + }); + const runQuery = vi.fn(async (_ref: unknown, args: Record) => { + if (typeof args.releaseId === "string") { + return await getScanInputHandler({ db } as never, { releaseId: args.releaseId }); + } + throw new Error(`unexpected query ${JSON.stringify(args)}`); + }); + + const processed = await processBatchHandler( + { runMutation, runQuery, storage: {}, scheduler } as never, + { + jobId: created.jobId, + batchSize: 1, + }, + ); + const listed = await listResultsHandler({ db } as never, { + actorUserId: "users:admin", + jobId: created.jobId, + cursor: null, + limit: 10, + }); + + expect(processed).toMatchObject({ + jobId: created.jobId, + enqueued: 1, + claimed: 1, + completed: 1, + done: true, + status: "completed", + }); + expect(jobs[0]).toMatchObject({ + selector: { kind: "allActive" }, + status: "completed", + totalItems: 1, + completedItems: 1, + matchedItems: 1, + }); + expect(items[0]).toMatchObject({ + status: "completed", + rawFsUsageCount: 1, + fsSafeUsageCount: 0, + }); + expect(listed).toMatchObject({ + jobStatus: "completed", + jobDone: true, + partial: false, + done: true, + nextCursor: null, + items: [ + { + jobId: created.jobId, + releaseId: "packageReleases:demo", + status: "completed", + rawFsUsageCount: 1, + }, + ], + }); + }); + + it("reclaims stale running scan results before claiming work", async () => { + const { db, jobs, items } = createDb(); + jobs.push({ + _id: "packageDryRunScanJobs:1", + scanner: "filesystem-safety-v1", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + status: "running", + requestedByUserId: "users:admin", + totalItems: 1, + queuedItems: 0, + runningItems: 1, + completedItems: 0, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + targetSelectionDone: true, + expiresAt: 1_700_086_400_000, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + items.push({ + _id: "packageDryRunScanResults:1", + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "running", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + leaseExpiresAt: 1, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + const scheduler = { runAfter: vi.fn(async () => undefined) }; + + const claimed = await claimResultsHandler({ db, scheduler } as never, { + jobId: "packageDryRunScanJobs:1", + batchSize: 1, + }); + + expect(claimed).toHaveLength(1); + expect(claimed[0]).toMatchObject({ itemId: "packageDryRunScanResults:1" }); + expect(items[0]).toMatchObject({ status: "running" }); + expect(items[0]?.leaseExpiresAt).toBeGreaterThan(Date.now()); + expect(jobs[0]).toMatchObject({ queuedItems: 0, runningItems: 1 }); + expect(scheduler.runAfter).toHaveBeenCalledTimes(1); + }); + + it("reclaims stale running scan results behind fresh running rows", async () => { + const { db, jobs, items } = createDb(); + jobs.push({ + _id: "packageDryRunScanJobs:1", + scanner: "filesystem-safety-v1", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + status: "running", + requestedByUserId: "users:admin", + totalItems: 26, + queuedItems: 0, + runningItems: 26, + completedItems: 0, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + targetSelectionDone: true, + expiresAt: 1_700_086_400_000, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + for (let index = 0; index < 25; index += 1) { + items.push({ + _id: `packageDryRunScanResults:fresh-${index}`, + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "running", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + leaseExpiresAt: Date.now() + 60_000, + createdAt: 1_700_000_000_000 + index, + updatedAt: 1_700_000_000_000, + }); + } + items.push({ + _id: "packageDryRunScanResults:stale", + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "running", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + leaseExpiresAt: 1, + createdAt: 1_700_000_000_100, + updatedAt: 1_700_000_000_000, + }); + const scheduler = { runAfter: vi.fn(async () => undefined) }; + + const claimed = await claimResultsHandler({ db, scheduler } as never, { + jobId: "packageDryRunScanJobs:1", + batchSize: 1, + }); + + expect(claimed).toHaveLength(1); + expect(claimed[0]).toMatchObject({ itemId: "packageDryRunScanResults:stale" }); + expect(items.at(-1)).toMatchObject({ status: "running" }); + expect(jobs[0]).toMatchObject({ queuedItems: 0, runningItems: 26 }); + }); + + it("updates a future stale recheck watchdog when it does not cover the new lease", async () => { + const { db, jobs, items } = createDb(); + const existingRecheckAt = Date.now() + 60_000; + jobs.push({ + _id: "packageDryRunScanJobs:1", + scanner: "filesystem-safety-v1", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + status: "running", + requestedByUserId: "users:admin", + totalItems: 2, + queuedItems: 2, + runningItems: 0, + completedItems: 0, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + targetSelectionDone: true, + staleRecheckAt: existingRecheckAt, + expiresAt: 1_700_086_400_000, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + for (const index of [1, 2]) { + items.push({ + _id: `packageDryRunScanResults:${index}`, + jobId: "packageDryRunScanJobs:1", + releaseId: `packageReleases:demo-${index}`, + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "queued", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + createdAt: 1_700_000_000_000 + index, + updatedAt: 1_700_000_000_000, + }); + } + const scheduler = { runAfter: vi.fn(async () => undefined) }; + + const firstClaim = await claimResultsHandler({ db, scheduler } as never, { + jobId: "packageDryRunScanJobs:1", + batchSize: 1, + }); + const secondClaim = await claimResultsHandler({ db, scheduler } as never, { + jobId: "packageDryRunScanJobs:1", + batchSize: 1, + }); + + expect(firstClaim).toHaveLength(1); + expect(secondClaim).toHaveLength(1); + expect(jobs[0]?.staleRecheckAt).toBeGreaterThan(existingRecheckAt); + expect(scheduler.runAfter).toHaveBeenCalledTimes(1); + }); + + it("reuses an existing stale recheck watchdog when it covers the new lease", async () => { + const { db, jobs, items } = createDb(); + const existingRecheckAt = Date.now() + 30 * 60_000; + jobs.push({ + _id: "packageDryRunScanJobs:1", + scanner: "filesystem-safety-v1", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + status: "running", + requestedByUserId: "users:admin", + totalItems: 1, + queuedItems: 1, + runningItems: 0, + completedItems: 0, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + targetSelectionDone: true, + staleRecheckAt: existingRecheckAt, + expiresAt: 1_700_086_400_000, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + items.push({ + _id: "packageDryRunScanResults:1", + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "queued", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + const scheduler = { runAfter: vi.fn(async () => undefined) }; + + const claimed = await claimResultsHandler({ db, scheduler } as never, { + jobId: "packageDryRunScanJobs:1", + batchSize: 1, + }); + + expect(claimed).toHaveLength(1); + expect(jobs[0]?.staleRecheckAt).toBe(existingRecheckAt); + expect(scheduler.runAfter).not.toHaveBeenCalled(); + }); + + it("prunes expired dry-run scan jobs and results", async () => { + const { db, jobs, items } = createDb(); + jobs.push({ + _id: "packageDryRunScanJobs:1", + scanner: "filesystem-safety-v1", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + status: "completed", + requestedByUserId: "users:admin", + totalItems: 1, + queuedItems: 0, + runningItems: 0, + completedItems: 1, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + targetSelectionDone: true, + expiresAt: 1, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + items.push({ + _id: "packageDryRunScanResults:1", + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "completed", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + + const scheduler = { runAfter: vi.fn(async (_delay: number, ..._args: unknown[]) => undefined) }; + + const result = await pruneJobsHandler({ db, scheduler } as never, { + jobBatchSize: 1, + resultBatchSize: 10, + }); + + expect(result).toEqual({ + jobsScanned: 1, + jobsDeleted: 1, + resultsDeleted: 1, + }); + expect(jobs).toHaveLength(0); + expect(items).toHaveLength(0); + }); + + it("prunes expired terminal jobs when older non-terminal jobs also expired", async () => { + const { db, jobs, items } = createDb(); + jobs.push( + { + _id: "packageDryRunScanJobs:queued", + scanner: "filesystem-safety-v1", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + status: "queued", + requestedByUserId: "users:admin", + totalItems: 1, + queuedItems: 1, + runningItems: 0, + completedItems: 0, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + targetSelectionDone: true, + expiresAt: 1, + createdAt: 1_699_999_999_000, + updatedAt: 1_699_999_999_000, + }, + { + _id: "packageDryRunScanJobs:completed", + scanner: "filesystem-safety-v1", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + status: "completed", + requestedByUserId: "users:admin", + totalItems: 1, + queuedItems: 0, + runningItems: 0, + completedItems: 1, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + targetSelectionDone: true, + expiresAt: 2, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }, + ); + items.push({ + _id: "packageDryRunScanResults:completed", + jobId: "packageDryRunScanJobs:completed", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "completed", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + + const scheduler = { runAfter: vi.fn(async (_delay: number, ..._args: unknown[]) => undefined) }; + + const result = await pruneJobsHandler({ db, scheduler } as never, { + jobBatchSize: 1, + resultBatchSize: 10, + }); + + expect(result).toEqual({ + jobsScanned: 1, + jobsDeleted: 1, + resultsDeleted: 1, + }); + expect(jobs.map((job) => job._id)).toEqual(["packageDryRunScanJobs:queued"]); + expect(items).toHaveLength(0); + }); + + it("prunes expired terminal dry-run scan jobs by expiry across statuses", async () => { + const { db, jobs, items } = createDb(); + jobs.push( + { + _id: "packageDryRunScanJobs:completed", + scanner: "filesystem-safety-v1", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + status: "completed", + requestedByUserId: "users:admin", + totalItems: 1, + queuedItems: 0, + runningItems: 0, + completedItems: 1, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + targetSelectionDone: true, + expiresAt: 100, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }, + { + _id: "packageDryRunScanJobs:failed", + scanner: "filesystem-safety-v1", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + status: "failed", + requestedByUserId: "users:admin", + totalItems: 1, + queuedItems: 0, + runningItems: 0, + completedItems: 0, + failedItems: 1, + skippedItems: 0, + matchedItems: 0, + targetSelectionDone: true, + expiresAt: 1, + createdAt: 1_700_000_000_001, + updatedAt: 1_700_000_000_001, + }, + ); + for (const job of jobs) { + items.push({ + _id: `${job._id}:result`, + jobId: job._id, + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: job.status === "failed" ? "failed" : "completed", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + } + + const scheduler = { runAfter: vi.fn(async (_delay: number, ..._args: unknown[]) => undefined) }; + + const result = await pruneJobsHandler({ db, scheduler } as never, { + jobBatchSize: 1, + resultBatchSize: 10, + }); + + expect(result).toEqual({ + jobsScanned: 1, + jobsDeleted: 1, + resultsDeleted: 1, + }); + expect(jobs.map((job) => job._id)).toEqual(["packageDryRunScanJobs:completed"]); + expect(items.map((item) => item.jobId)).toEqual(["packageDryRunScanJobs:completed"]); + expect(scheduler.runAfter).toHaveBeenCalledTimes(1); + }); + + it("bounds total pruned results per invocation across expired jobs", async () => { + const { db, jobs, items } = createDb(); + for (const jobId of ["packageDryRunScanJobs:1", "packageDryRunScanJobs:2"]) { + jobs.push({ + _id: jobId, + scanner: "filesystem-safety-v1", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + status: "completed", + requestedByUserId: "users:admin", + totalItems: 2, + queuedItems: 0, + runningItems: 0, + completedItems: 2, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + targetSelectionDone: true, + expiresAt: 1, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + for (let index = 0; index < 2; index += 1) { + items.push({ + _id: `${jobId}:result-${index}`, + jobId, + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "completed", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + } + } + + const scheduler = { runAfter: vi.fn(async (_delay: number, ..._args: unknown[]) => undefined) }; + + const result = await pruneJobsHandler({ db, scheduler } as never, { + jobBatchSize: 2, + resultBatchSize: 2, + }); + + expect(result).toEqual({ + jobsScanned: 2, + jobsDeleted: 0, + resultsDeleted: 2, + }); + expect(jobs).toHaveLength(2); + expect(items).toHaveLength(2); + expect(scheduler.runAfter).toHaveBeenCalledTimes(1); + expect(scheduler.runAfter.mock.calls[0]?.[0]).toBe(0); + }); + + it("processes a bounded worker batch without patching package release state", async () => { + vi.mocked(runPackageDryRunFilesystemScan).mockResolvedValue({ + rawFsUsage: { + reasonCode: "info.filesystem.raw_fs_api_usage", + totalCount: 0, + returnedCount: 0, + omittedCount: 0, + truncatedEvidenceCount: 0, + evidence: [], + }, + fsSafeUsage: { + reasonCode: "info.filesystem.fs_safe_usage", + totalCount: 0, + returnedCount: 0, + omittedCount: 0, + truncatedEvidenceCount: 0, + evidence: [], + }, + }); + let jobOnlyMutationCalls = 0; + const runMutation = vi.fn(async (_ref: unknown, args: Record) => { + if (args.jobId === "packageDryRunScanJobs:1" && Object.keys(args).length === 1) { + jobOnlyMutationCalls += 1; + if (jobOnlyMutationCalls === 1) { + return { enqueued: 0, done: true }; + } + return { done: true, status: "completed" }; + } + if (args.jobId === "packageDryRunScanJobs:1" && args.batchSize === 2) { + return [ + { + itemId: "packageDryRunScanResults:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + claimToken: "claim-1", + }, + ]; + } + if (args.itemId === "packageDryRunScanResults:1" && args.result) return null; + throw new Error(`unexpected mutation ${JSON.stringify(args)}`); + }); + const runQuery = vi.fn(async (_ref: unknown, args: Record) => { + if (args.releaseId !== "packageReleases:demo") { + throw new Error(`unexpected query ${JSON.stringify(args)}`); + } + return { + kind: "scan", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + packageSummary: "A demo package", + metadata: { + packageJson: { name: "demo-plugin" }, + pluginManifest: { id: "demo-plugin" }, + }, + files: [], + }; + }); + + const scheduler = { runAfter: vi.fn(async () => undefined) }; + const result = await processBatchHandler( + { runMutation, runQuery, storage: {}, scheduler } as never, + { + jobId: "packageDryRunScanJobs:1", + batchSize: 2, + }, + ); + + expect(result).toEqual({ + jobId: "packageDryRunScanJobs:1", + enqueued: 0, + claimed: 1, + completed: 1, + skipped: 0, + failed: 0, + done: true, + status: "completed", + }); + expect(runPackageDryRunFilesystemScan).toHaveBeenCalledWith( + expect.objectContaining({ runMutation, runQuery }), + expect.objectContaining({ + files: [], + }), + ); + expect(runMutation).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + releaseId: "packageReleases:demo", + staticScan: expect.anything(), + }), + ); + expect(scheduler.runAfter).not.toHaveBeenCalled(); + }); + + it("records scan read failures separately from scanner findings", async () => { + const { db, jobs, items } = createDb(); + jobs.push({ + _id: "packageDryRunScanJobs:1", + scanner: "filesystem-safety-v1", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + status: "running", + requestedByUserId: "users:admin", + totalItems: 1, + queuedItems: 0, + runningItems: 1, + completedItems: 0, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + targetSelectionDone: true, + expiresAt: 1_700_086_400_000, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + items.push({ + _id: "packageDryRunScanResults:1", + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "running", + claimToken: "claim-1", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + + await failResultHandler({ db } as never, { + itemId: "packageDryRunScanResults:1", + claimToken: "claim-1", + error: "storage object missing", + }); + + expect(items[0]).toMatchObject({ + status: "failed", + findings: [], + errors: ["storage object missing"], + }); + expect(jobs[0]).toMatchObject({ + runningItems: 0, + failedItems: 1, + }); + }); + + it("bounds persisted skipped-result reasons", async () => { + const { db, jobs, items } = createDb(); + jobs.push({ + _id: "packageDryRunScanJobs:1", + scanner: "filesystem-safety-v1", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + status: "running", + requestedByUserId: "users:admin", + totalItems: 1, + queuedItems: 0, + runningItems: 1, + completedItems: 0, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + targetSelectionDone: true, + expiresAt: 1_700_086_400_000, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + items.push({ + _id: "packageDryRunScanResults:1", + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "running", + claimToken: "claim-1", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + + await skipResultHandler({ db } as never, { + itemId: "packageDryRunScanResults:1", + claimToken: "claim-1", + reason: "x".repeat(2_000), + }); + + expect(items[0]?.errors[0]).toHaveLength(1_024); + expect(items[0]?.errors[0]?.endsWith("...")).toBe(true); + expect(jobs[0]).toMatchObject({ + runningItems: 0, + skippedItems: 1, + }); + }); + + it("ignores stale worker completions after a result is requeued", async () => { + const { db, jobs, items } = createDb(); + jobs.push({ + _id: "packageDryRunScanJobs:1", + scanner: "filesystem-safety-v1", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + status: "running", + requestedByUserId: "users:admin", + totalItems: 1, + queuedItems: 1, + runningItems: 0, + completedItems: 0, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + targetSelectionDone: true, + expiresAt: 1_700_086_400_000, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + items.push({ + _id: "packageDryRunScanResults:1", + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "queued", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + + await failResultHandler({ db } as never, { + itemId: "packageDryRunScanResults:1", + claimToken: "old-claim", + error: "late worker failed", + }); + + expect(items[0]).toMatchObject({ + status: "queued", + errors: [], + }); + expect(jobs[0]).toMatchObject({ + queuedItems: 1, + runningItems: 0, + failedItems: 0, + }); + }); + + it("preserves failed terminal jobs when a later finalize runs", async () => { + const { db, jobs } = createDb(); + jobs.push({ + _id: "packageDryRunScanJobs:1", + scanner: "filesystem-safety-v1", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + status: "failed", + requestedByUserId: "users:admin", + totalItems: 0, + queuedItems: 0, + runningItems: 0, + completedItems: 0, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + targetSelectionDone: true, + expiresAt: 1_700_086_400_000, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + completedAt: 1_700_000_000_000, + error: "worker unavailable", + }); + + const result = await finalizeJobHandler({ db } as never, { + jobId: "packageDryRunScanJobs:1", + }); + + expect(result).toEqual({ done: true, status: "failed" }); + expect(jobs[0]).toMatchObject({ + status: "failed", + error: "worker unavailable", + }); + expect(db.patch).not.toHaveBeenCalled(); + }); + + it("ignores late result completions after the parent job is terminal", async () => { + const { db, jobs, items } = createDb(); + jobs.push({ + _id: "packageDryRunScanJobs:1", + scanner: "filesystem-safety-v1", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + status: "failed", + requestedByUserId: "users:admin", + totalItems: 1, + queuedItems: 0, + runningItems: 0, + completedItems: 0, + failedItems: 1, + skippedItems: 0, + matchedItems: 0, + targetSelectionDone: true, + expiresAt: 1_700_086_400_000, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + completedAt: 1_700_000_000_000, + error: "worker unavailable", + }); + items.push({ + _id: "packageDryRunScanResults:1", + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "running", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + claimToken: "claim-1", + leaseExpiresAt: 1_700_000_600_000, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + }); + + await completeResultHandler({ db } as never, { + itemId: "packageDryRunScanResults:1", + claimToken: "claim-1", + result: { + rawFsUsage: { + reasonCode: "info.filesystem.raw_fs_api_usage", + totalCount: 1, + returnedCount: 1, + omittedCount: 0, + truncatedEvidenceCount: 0, + evidence: [ + { + code: "info.filesystem.raw_fs_api_usage", + severity: "info", + file: "dist/index.js", + line: 1, + message: "Raw Node filesystem API usage detected.", + evidence: "import fs from 'node:fs';", + evidenceTruncated: false, + }, + ], + }, + fsSafeUsage: { + reasonCode: "info.filesystem.fs_safe_usage", + totalCount: 0, + returnedCount: 0, + omittedCount: 0, + truncatedEvidenceCount: 0, + evidence: [], + }, + }, + }); + + expect(items[0]).toMatchObject({ + status: "running", + rawFsUsageCount: 0, + claimToken: "claim-1", + }); + expect(jobs[0]).toMatchObject({ + status: "failed", + completedItems: 0, + failedItems: 1, + matchedItems: 0, + }); + }); + + it("schedules a continuation when the worker batch is not done", async () => { + let jobOnlyMutationCalls = 0; + const runMutation = vi.fn(async (_ref: unknown, args: Record) => { + if (args.jobId === "packageDryRunScanJobs:1" && Object.keys(args).length === 1) { + jobOnlyMutationCalls += 1; + if (jobOnlyMutationCalls === 1) return { enqueued: 1, done: false }; + return { done: false, status: "running" }; + } + if (args.jobId === "packageDryRunScanJobs:1" && args.batchSize === 5) return []; + throw new Error(`unexpected mutation ${JSON.stringify(args)}`); + }); + const scheduler = { runAfter: vi.fn(async (_delay: number, ..._args: unknown[]) => undefined) }; + + const result = await processBatchHandler( + { runMutation, runQuery: vi.fn(), storage: {}, scheduler } as never, + { + jobId: "packageDryRunScanJobs:1", + batchSize: 5, + }, + ); + + expect(result).toMatchObject({ + jobId: "packageDryRunScanJobs:1", + enqueued: 1, + claimed: 0, + done: false, + status: "running", + }); + expect(scheduler.runAfter).toHaveBeenCalledTimes(1); + expect(scheduler.runAfter.mock.calls[0]?.[0]).toBe(0); + }); + + it("continues immediately when target selection advanced without queued targets", async () => { + let jobOnlyMutationCalls = 0; + const runMutation = vi.fn(async (_ref: unknown, args: Record) => { + if (args.jobId === "packageDryRunScanJobs:1" && Object.keys(args).length === 1) { + jobOnlyMutationCalls += 1; + if (jobOnlyMutationCalls === 1) return { enqueued: 0, done: false, advanced: true }; + return { done: false, status: "running" }; + } + if (args.jobId === "packageDryRunScanJobs:1" && args.batchSize === 5) return []; + throw new Error(`unexpected mutation ${JSON.stringify(args)}`); + }); + const scheduler = { runAfter: vi.fn(async (_delay: number, ..._args: unknown[]) => undefined) }; + + const result = await processBatchHandler( + { runMutation, runQuery: vi.fn(), storage: {}, scheduler } as never, + { + jobId: "packageDryRunScanJobs:1", + batchSize: 5, + }, + ); + + expect(result).toMatchObject({ + jobId: "packageDryRunScanJobs:1", + enqueued: 0, + claimed: 0, + done: false, + status: "running", + }); + expect(scheduler.runAfter).toHaveBeenCalledTimes(1); + expect(scheduler.runAfter.mock.calls[0]?.[0]).toBe(0); + }); + + it("backs off instead of hot-looping when only running items remain", async () => { + let jobOnlyMutationCalls = 0; + const runMutation = vi.fn(async (_ref: unknown, args: Record) => { + if (args.jobId === "packageDryRunScanJobs:1" && Object.keys(args).length === 1) { + jobOnlyMutationCalls += 1; + if (jobOnlyMutationCalls === 1) return { enqueued: 0, done: true, advanced: false }; + return { done: false, status: "running" }; + } + if (args.jobId === "packageDryRunScanJobs:1" && args.batchSize === 5) return []; + throw new Error(`unexpected mutation ${JSON.stringify(args)}`); + }); + const scheduler = { runAfter: vi.fn(async (_delay: number, ..._args: unknown[]) => undefined) }; + + const result = await processBatchHandler( + { runMutation, runQuery: vi.fn(), storage: {}, scheduler } as never, + { + jobId: "packageDryRunScanJobs:1", + batchSize: 5, + }, + ); + + expect(result).toMatchObject({ + jobId: "packageDryRunScanJobs:1", + enqueued: 0, + claimed: 0, + done: false, + status: "running", + }); + expect(scheduler.runAfter).toHaveBeenCalledTimes(1); + expect(scheduler.runAfter.mock.calls[0]?.[0]).toBeGreaterThan(0); + }); + + it("marks the job failed when the worker action cannot continue", async () => { + const runMutation = vi.fn(async (_ref: unknown, args: Record) => { + if ( + args.jobId === "packageDryRunScanJobs:1" && + Object.keys(args).length === 1 && + runMutation.mock.calls.length === 1 + ) { + throw new Error("target enqueue unavailable"); + } + if (args.jobId === "packageDryRunScanJobs:1" && args.error === "target enqueue unavailable") { + return { done: true, status: "failed" }; + } + throw new Error(`unexpected mutation ${JSON.stringify(args)}`); + }); + const scheduler = { runAfter: vi.fn(async (_delay: number, ..._args: unknown[]) => undefined) }; + + const result = await processBatchHandler( + { runMutation, runQuery: vi.fn(), storage: {}, scheduler } as never, + { + jobId: "packageDryRunScanJobs:1", + batchSize: 5, + }, + ); + + expect(result).toMatchObject({ + jobId: "packageDryRunScanJobs:1", + enqueued: 0, + claimed: 0, + completed: 0, + skipped: 0, + failed: 0, + done: true, + status: "failed", + }); + expect(scheduler.runAfter).not.toHaveBeenCalled(); + }); +}); diff --git a/convex/packageDryRunScans.ts b/convex/packageDryRunScans.ts new file mode 100644 index 0000000000..57ae9e51e4 --- /dev/null +++ b/convex/packageDryRunScans.ts @@ -0,0 +1,1202 @@ +import { ConvexError, v } from "convex/values"; +import { internal } from "./_generated/api"; +import type { Doc, Id } from "./_generated/dataModel"; +import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server"; +import { internalAction, internalMutation, internalQuery, mutation } from "./functions"; +import { assertAdmin, requireUser } from "./lib/access"; +import { runPackageDryRunFilesystemScan } from "./lib/packageDryRunFilesystemScan"; +import { normalizePackageName } from "./lib/packageRegistry"; + +const MAX_CREATE_RELEASES = 200; +const MAX_LATEST_ACTIVE_LIMIT = 200; +const MAX_SAMPLE_CANDIDATES = 1_000; +const MAX_SAMPLE_SEED_CHARS = 128; +const MAX_SELECTOR_SCAN_PAGES = 10; +const MAX_PROCESS_BATCH_SIZE = 25; +const SCANNER_PROFILE = "filesystem-safety-v1"; +const RUNNING_LEASE_MS = 10 * 60_000; +const STALLED_JOB_RECHECK_MS = RUNNING_LEASE_MS + 1_000; +const JOB_RETENTION_MS = 14 * 24 * 60 * 60_000; +const PRUNE_JOB_BATCH_SIZE = 100; +const PRUNE_RESULT_BATCH_SIZE = 1_000; +const MAX_PERSISTED_ERROR_CHARS = 1_024; +const TRUNCATED_ERROR_SUFFIX = "..."; +const TERMINAL_JOB_STATUSES = ["completed", "failed"] as const; + +const selectorValidator = v.union( + v.object({ + kind: v.literal("releaseIds"), + releaseIds: v.array(v.id("packageReleases")), + }), + v.object({ + kind: v.literal("packageNames"), + packageNames: v.array(v.string()), + }), + v.object({ + kind: v.literal("latestActive"), + limit: v.number(), + }), + v.object({ + kind: v.literal("allActive"), + }), + v.object({ + kind: v.literal("seededSample"), + limit: v.number(), + seed: v.string(), + maxCandidates: v.number(), + }), +); + +const filesystemFindingValidator = v.object({ + code: v.string(), + severity: v.string(), + file: v.string(), + line: v.number(), + message: v.string(), + evidence: v.string(), + evidenceTruncated: v.boolean(), +}); + +const filesystemBucketValidator = v.object({ + reasonCode: v.string(), + totalCount: v.number(), + returnedCount: v.number(), + omittedCount: v.number(), + truncatedEvidenceCount: v.number(), + evidence: v.array(filesystemFindingValidator), +}); + +const filesystemEvidenceValidator = v.object({ + rawFsUsage: filesystemBucketValidator, + fsSafeUsage: filesystemBucketValidator, +}); + +const internalRefs = internal as unknown as { + packageDryRunScans: { + claimPackageDryRunScanResultsInternal: unknown; + completePackageDryRunScanResultInternal: unknown; + enqueuePackageDryRunScanJobTargetsInternal: unknown; + skipPackageDryRunScanResultInternal: unknown; + failPackageDryRunScanResultInternal: unknown; + failPackageDryRunScanJobInternal: unknown; + finalizePackageDryRunScanJobInternal: unknown; + getPackageDryRunScanInputInternal: unknown; + processPackageDryRunScanJobBatchInternal: unknown; + prunePackageDryRunScansInternal: unknown; + }; +}; + +type DbReadCtx = Pick; +type DryRunSelector = + | { kind: "releaseIds"; releaseIds: Array> } + | { kind: "packageNames"; packageNames: string[] } + | { kind: "latestActive"; limit: number } + | { kind: "allActive" } + | { kind: "seededSample"; limit: number; seed: string; maxCandidates: number }; +type DryRunTarget = { + releaseId: Id<"packageReleases">; + packageId: Id<"packages">; + packageName: string; + packageDisplayName: string; + version: string; + createdAt: number; +}; +type DryRunTargetSelection = { + targets: DryRunTarget[]; + candidateLimitReached?: boolean; + targetLimitReached?: boolean; + selectionScanLimitReached?: boolean; +}; +type ClaimedItem = Omit & { + itemId: Id<"packageDryRunScanResults">; + claimToken: string; +}; +type ScanInput = + | { + kind: "scan"; + files: Doc<"packageReleases">["files"]; + } + | { + kind: "skip"; + reason: + | "missing_release" + | "missing_package" + | "soft_deleted_release" + | "soft_deleted_package" + | "unsupported_family"; + }; + +async function runQueryRef( + ctx: { runQuery: (ref: never, args: never) => Promise }, + ref: unknown, + args: unknown, +): Promise { + return (await ctx.runQuery(ref as never, args as never)) as T; +} + +async function runMutationRef( + ctx: { runMutation: (ref: never, args: never) => Promise }, + ref: unknown, + args: unknown, +): Promise { + return (await ctx.runMutation(ref as never, args as never)) as T; +} + +function normalizePositiveLimit(value: number, max: number, label: string) { + if (!Number.isInteger(value) || value < 1) { + throw new ConvexError(`${label} must be a positive integer`); + } + if (value > max) { + throw new ConvexError(`${label} must be at most ${max}`); + } + return value; +} + +function uniqueReleaseIds(releaseIds: Array>) { + const seen = new Set>(); + const unique: Array> = []; + for (const releaseId of releaseIds) { + if (seen.has(releaseId)) continue; + seen.add(releaseId); + unique.push(releaseId); + } + return unique; +} + +function formatUnresolvedSelectorValues(values: readonly string[]) { + const visible = values.slice(0, 5).join(", "); + const omitted = values.length > 5 ? ` and ${values.length - 5} more` : ""; + return `${visible}${omitted}`; +} + +async function resolveDryRunTarget( + ctx: DbReadCtx, + releaseId: Id<"packageReleases">, +): Promise { + const release = await ctx.db.get(releaseId); + if (!release) return null; + return await resolveDryRunTargetFromRelease(ctx, release); +} + +async function resolveDryRunTargetFromRelease( + ctx: DbReadCtx, + release: Doc<"packageReleases">, + options: { requireLatestRelease?: boolean } = {}, +): Promise { + if (release.softDeletedAt) return null; + const pkg = await ctx.db.get(release.packageId); + if (!pkg || pkg.softDeletedAt || pkg.family === "skill") return null; + if (options.requireLatestRelease && pkg.latestReleaseId !== release._id) return null; + + return { + releaseId: release._id, + packageId: pkg._id, + packageName: pkg.name, + packageDisplayName: pkg.displayName, + version: release.version, + createdAt: release.createdAt, + }; +} + +async function selectExplicitReleaseTargets( + ctx: DbReadCtx, + releaseIds: Array>, +): Promise { + if (releaseIds.length === 0) throw new ConvexError("releaseIds must not be empty"); + if (releaseIds.length > MAX_CREATE_RELEASES) { + throw new ConvexError(`releaseIds is limited to ${MAX_CREATE_RELEASES} releases`); + } + + const targets: DryRunTarget[] = []; + const unresolvedReleaseIds: string[] = []; + for (const releaseId of uniqueReleaseIds(releaseIds)) { + const target = await resolveDryRunTarget(ctx, releaseId); + if (target) { + targets.push(target); + } else { + unresolvedReleaseIds.push(releaseId); + } + } + if (unresolvedReleaseIds.length > 0) { + throw new ConvexError( + `Dry-run scan selector could not resolve releaseIds: ${formatUnresolvedSelectorValues(unresolvedReleaseIds)}`, + ); + } + return { targets }; +} + +async function selectPackageNameTargets( + ctx: DbReadCtx, + packageNames: string[], +): Promise { + if (packageNames.length === 0) throw new ConvexError("packageNames must not be empty"); + if (packageNames.length > MAX_CREATE_RELEASES) { + throw new ConvexError(`packageNames is limited to ${MAX_CREATE_RELEASES} packages`); + } + + const normalizedNames = [...new Set(packageNames.map((name) => normalizePackageName(name)))]; + const targets: DryRunTarget[] = []; + const seenReleases = new Set>(); + const unresolvedPackageNames: string[] = []; + for (const normalizedName of normalizedNames) { + const pkg = await ctx.db + .query("packages") + .withIndex("by_name", (q) => q.eq("normalizedName", normalizedName)) + .unique(); + if (!pkg || pkg.softDeletedAt || pkg.family === "skill" || !pkg.latestReleaseId) { + unresolvedPackageNames.push(normalizedName); + continue; + } + if (seenReleases.has(pkg.latestReleaseId)) continue; + + const target = await resolveDryRunTarget(ctx, pkg.latestReleaseId); + if (!target) { + unresolvedPackageNames.push(normalizedName); + continue; + } + seenReleases.add(target.releaseId); + targets.push(target); + } + if (unresolvedPackageNames.length > 0) { + throw new ConvexError( + `Dry-run scan selector could not resolve packageNames: ${formatUnresolvedSelectorValues(unresolvedPackageNames)}`, + ); + } + return { targets }; +} + +async function selectLatestActiveTargets( + ctx: DbReadCtx, + limit: number, + max: number = MAX_LATEST_ACTIVE_LIMIT, + options: { detectLimitReached?: boolean } = {}, +): Promise { + const boundedLimit = normalizePositiveLimit(limit, max, "limit"); + const collectLimit = options.detectLimitReached ? boundedLimit + 1 : boundedLimit; + const selectorScanPages = options.detectLimitReached + ? Math.max(MAX_SELECTOR_SCAN_PAGES, Math.ceil(collectLimit / 100)) + : MAX_SELECTOR_SCAN_PAGES; + + const targets: DryRunTarget[] = []; + const seenReleases = new Set>(); + let cursor: string | null = null; + let done = false; + let pagesScanned = 0; + let unresolvedBoundary = false; + let boundaryResolved = false; + while (!done && pagesScanned < selectorScanPages) { + const page = await ctx.db + .query("packageReleases") + .withIndex("by_active_created", (q) => q.eq("softDeletedAt", undefined)) + .order("desc") + .paginate({ cursor, numItems: 100 }); + pagesScanned += 1; + let oldestPageCreatedAt: number | null = null; + for (const release of page.page) { + oldestPageCreatedAt = + oldestPageCreatedAt === null + ? release.createdAt + : Math.min(oldestPageCreatedAt, release.createdAt); + if (seenReleases.has(release._id)) continue; + const target = await resolveDryRunTargetFromRelease(ctx, release, { + requireLatestRelease: true, + }); + if (!target) continue; + seenReleases.add(target.releaseId); + targets.push(target); + } + targets.sort( + (left, right) => + right.createdAt - left.createdAt || left.releaseId.localeCompare(right.releaseId), + ); + done = page.isDone; + cursor = page.continueCursor; + const boundaryCreatedAt = + targets.length >= collectLimit ? targets[collectLimit - 1]?.createdAt : undefined; + if ( + boundaryCreatedAt !== undefined && + (done || (oldestPageCreatedAt !== null && oldestPageCreatedAt < boundaryCreatedAt)) + ) { + boundaryResolved = true; + break; + } + if (!cursor && !done) break; + } + const hitUnresolvedScanLimit = !done && pagesScanned >= selectorScanPages && !boundaryResolved; + if (hitUnresolvedScanLimit) { + const boundaryCreatedAt = + targets.length >= collectLimit ? targets[collectLimit - 1]?.createdAt : undefined; + const oldestCollectedCreatedAt = targets.at(-1)?.createdAt; + unresolvedBoundary = + options.detectLimitReached || + (boundaryCreatedAt !== undefined && oldestCollectedCreatedAt === boundaryCreatedAt); + } + return { + targets: targets.slice(0, boundedLimit), + targetLimitReached: targets.length > boundedLimit || hitUnresolvedScanLimit, + selectionScanLimitReached: unresolvedBoundary, + }; +} + +async function selectSeededSampleTargets( + ctx: DbReadCtx, + selector: { limit: number; seed: string; maxCandidates: number }, +): Promise { + const boundedLimit = normalizePositiveLimit(selector.limit, MAX_LATEST_ACTIVE_LIMIT, "limit"); + const seed = normalizeSeededSampleSeed(selector.seed); + const maxCandidates = normalizePositiveLimit( + selector.maxCandidates, + MAX_SAMPLE_CANDIDATES, + "maxCandidates", + ); + if (maxCandidates < boundedLimit) { + throw new ConvexError("maxCandidates must be greater than or equal to limit"); + } + const candidateSelection = await selectLatestActiveTargets( + ctx, + maxCandidates, + MAX_SAMPLE_CANDIDATES, + { detectLimitReached: true }, + ); + const targets = candidateSelection.targets + .map((target) => ({ + target, + score: deterministicSampleScore(`${seed}:${target.releaseId}`), + })) + .sort( + (left, right) => + left.score - right.score || left.target.releaseId.localeCompare(right.target.releaseId), + ) + .slice(0, boundedLimit) + .map(({ target }) => target); + return { + targets, + candidateLimitReached: candidateSelection.targetLimitReached, + targetLimitReached: candidateSelection.targetLimitReached, + selectionScanLimitReached: candidateSelection.selectionScanLimitReached, + }; +} + +async function selectDryRunTargets( + ctx: DbReadCtx, + selector: Exclude, +): Promise { + if (selector.kind === "releaseIds") { + return await selectExplicitReleaseTargets(ctx, selector.releaseIds); + } + if (selector.kind === "packageNames") { + return await selectPackageNameTargets(ctx, selector.packageNames); + } + if (selector.kind === "latestActive") { + return await selectLatestActiveTargets(ctx, selector.limit); + } + return await selectSeededSampleTargets(ctx, selector); +} + +function requireActiveAdminUser(actor: Doc<"users"> | null) { + if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized"); + assertAdmin(actor); +} + +function errorMessage(error: unknown) { + return truncateDryRunErrorMessage(error instanceof Error ? error.message : String(error)); +} + +function truncateDryRunErrorMessage(message: string) { + if (message.length <= MAX_PERSISTED_ERROR_CHARS) return message; + return `${message.slice(0, MAX_PERSISTED_ERROR_CHARS - TRUNCATED_ERROR_SUFFIX.length)}${TRUNCATED_ERROR_SUFFIX}`; +} + +function deterministicSampleScore(value: string) { + let hash = 2_166_136_261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16_777_619); + } + return hash >>> 0; +} + +function normalizeSeededSampleSeed(seed: string) { + const trimmed = seed.trim(); + if (!trimmed) throw new ConvexError("seed must be a non-empty string"); + if (trimmed.length > MAX_SAMPLE_SEED_CHARS) { + throw new ConvexError(`seed must be at most ${MAX_SAMPLE_SEED_CHARS} characters`); + } + return trimmed; +} + +function normalizeDryRunSelector(selector: DryRunSelector): DryRunSelector { + if (selector.kind !== "seededSample") return selector; + return { + ...selector, + seed: normalizeSeededSampleSeed(selector.seed), + }; +} + +function filesystemMatched(result: { + rawFsUsage: { totalCount: number }; + fsSafeUsage: { totalCount: number }; +}) { + return result.rawFsUsage.totalCount > 0 || result.fsSafeUsage.totalCount > 0 ? 1 : 0; +} + +function isTerminalDryRunJobStatus(status: string) { + return status === "completed" || status === "failed"; +} + +async function schedulePackageDryRunScanJob( + ctx: Pick, + jobId: Id<"packageDryRunScanJobs">, +) { + await ctx.scheduler.runAfter( + 0, + internalRefs.packageDryRunScans.processPackageDryRunScanJobBatchInternal as never, + { jobId } as never, + ); +} + +async function insertPackageDryRunScanJob( + ctx: Pick, + requestedByUserId: Id<"users">, + selector: DryRunSelector, +) { + const normalizedSelector = normalizeDryRunSelector(selector); + const selection = + normalizedSelector.kind === "allActive" + ? { targets: [] } + : await selectDryRunTargets(ctx, normalizedSelector); + const requestedTargetCount = + normalizedSelector.kind === "latestActive" || normalizedSelector.kind === "seededSample" + ? normalizedSelector.limit + : null; + if ( + selection.selectionScanLimitReached || + (requestedTargetCount !== null && + selection.targetLimitReached && + selection.targets.length < requestedTargetCount) + ) { + throw new ConvexError( + selection.selectionScanLimitReached + ? "Dry-run scan selector reached selection scan limit before resolving release ordering" + : "Dry-run scan selector reached selection scan limit before collecting requested releases", + ); + } + if (normalizedSelector.kind !== "allActive" && selection.targets.length === 0) { + throw new ConvexError("No active package releases matched the dry-run scan selector"); + } + + const now = Date.now(); + const jobId = await ctx.db.insert("packageDryRunScanJobs", { + scanner: SCANNER_PROFILE, + selector: normalizedSelector, + status: "queued", + requestedByUserId, + totalItems: selection.targets.length, + queuedItems: selection.targets.length, + runningItems: 0, + completedItems: 0, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + cursor: null, + targetSelectionDone: normalizedSelector.kind !== "allActive", + candidateLimitReached: selection.candidateLimitReached, + expiresAt: now + JOB_RETENTION_MS, + createdAt: now, + updatedAt: now, + }); + + for (const target of selection.targets) { + await ctx.db.insert("packageDryRunScanResults", { + jobId, + releaseId: target.releaseId, + packageId: target.packageId, + packageName: target.packageName, + packageDisplayName: target.packageDisplayName, + version: target.version, + status: "queued", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + createdAt: now, + updatedAt: now, + }); + } + + await schedulePackageDryRunScanJob(ctx, jobId); + + return { + jobId, + status: "queued" as const, + totalItems: selection.targets.length, + targetSelectionDone: selector.kind !== "allActive", + candidateLimitReached: selection.candidateLimitReached, + }; +} + +export const createPackageDryRunScanJob = mutation({ + args: { + selector: selectorValidator, + }, + handler: async (ctx, args) => { + const { userId, user } = await requireUser(ctx); + assertAdmin(user); + + return await insertPackageDryRunScanJob(ctx, userId, args.selector); + }, +}); + +export const createPackageDryRunScanJobForUserInternal = internalMutation({ + args: { + actorUserId: v.id("users"), + selector: selectorValidator, + }, + handler: async (ctx, args) => { + const actor = await ctx.db.get(args.actorUserId); + requireActiveAdminUser(actor); + return await insertPackageDryRunScanJob(ctx, args.actorUserId, args.selector); + }, +}); + +export const getPackageDryRunScanJobForUserInternal = internalQuery({ + args: { + actorUserId: v.id("users"), + jobId: v.id("packageDryRunScanJobs"), + }, + handler: async (ctx, args) => { + const actor = await ctx.db.get(args.actorUserId); + requireActiveAdminUser(actor); + + const job = await ctx.db.get(args.jobId); + if (!job) throw new ConvexError("Package dry-run scan job not found"); + const outstandingItems = job.status === "failed" ? job.queuedItems + job.runningItems : 0; + return { + jobId: job._id, + scanner: job.scanner, + selector: job.selector, + status: job.status, + totalItems: job.totalItems, + queuedItems: job.status === "failed" ? 0 : job.queuedItems, + runningItems: job.status === "failed" ? 0 : job.runningItems, + completedItems: job.completedItems, + failedItems: job.failedItems + outstandingItems, + skippedItems: job.skippedItems, + matchedItems: job.matchedItems, + targetSelectionDone: job.targetSelectionDone !== false, + candidateLimitReached: job.candidateLimitReached, + expiresAt: job.expiresAt, + error: job.error, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + startedAt: job.startedAt, + completedAt: job.completedAt, + }; + }, +}); + +export const listPackageDryRunScanResultsForUserInternal = internalQuery({ + args: { + actorUserId: v.id("users"), + jobId: v.id("packageDryRunScanJobs"), + cursor: v.union(v.string(), v.null()), + limit: v.number(), + }, + handler: async (ctx, args) => { + const actor = await ctx.db.get(args.actorUserId); + requireActiveAdminUser(actor); + + const job = await ctx.db.get(args.jobId); + if (!job) throw new ConvexError("Package dry-run scan job not found"); + + const page = await ctx.db + .query("packageDryRunScanResults") + .withIndex("by_job_created", (q) => q.eq("jobId", args.jobId)) + .paginate({ + cursor: args.cursor, + numItems: normalizePositiveLimit(args.limit, 500, "limit"), + }); + + const jobDone = isTerminalDryRunJobStatus(job.status); + const partial = !jobDone || !job.targetSelectionDone; + + return { + jobStatus: job.status, + jobDone, + partial, + items: page.page.map((item) => { + const terminalFailed = + job.status === "failed" && (item.status === "queued" || item.status === "running"); + return { + itemId: item._id, + jobId: item.jobId, + releaseId: item.releaseId, + packageId: item.packageId, + packageName: item.packageName, + packageDisplayName: item.packageDisplayName, + version: item.version, + status: terminalFailed ? "failed" : item.status, + rawFsUsageCount: item.rawFsUsageCount, + fsSafeUsageCount: item.fsSafeUsageCount, + findings: item.findings, + errors: terminalFailed ? [job.error ?? "Dry-run scan job failed"] : item.errors, + createdAt: item.createdAt, + updatedAt: terminalFailed ? job.updatedAt : item.updatedAt, + startedAt: item.startedAt, + completedAt: terminalFailed ? job.completedAt : item.completedAt, + }; + }), + nextCursor: page.isDone ? null : page.continueCursor, + done: page.isDone, + }; + }, +}); + +export const enqueuePackageDryRunScanJobTargetsInternal = internalMutation({ + args: { + jobId: v.id("packageDryRunScanJobs"), + }, + handler: async (ctx, args) => { + const job = await ctx.db.get(args.jobId); + if (!job || job.status === "completed" || job.status === "failed") { + return { enqueued: 0, done: true as const, advanced: false }; + } + if (job.selector.kind !== "allActive" || job.targetSelectionDone) { + return { enqueued: 0, done: true as const, advanced: false }; + } + + const page = await ctx.db + .query("packageReleases") + .withIndex("by_active_created", (q) => q.eq("softDeletedAt", undefined)) + .order("desc") + .paginate({ cursor: job.cursor ?? null, numItems: MAX_PROCESS_BATCH_SIZE }); + + const now = Date.now(); + let enqueued = 0; + for (const release of page.page) { + const target = await resolveDryRunTargetFromRelease(ctx, release); + if (!target) continue; + const existing = await ctx.db + .query("packageDryRunScanResults") + .withIndex("by_job_release", (q) => + q.eq("jobId", args.jobId).eq("releaseId", target.releaseId), + ) + .unique(); + if (existing) continue; + await ctx.db.insert("packageDryRunScanResults", { + jobId: args.jobId, + releaseId: target.releaseId, + packageId: target.packageId, + packageName: target.packageName, + packageDisplayName: target.packageDisplayName, + version: target.version, + status: "queued", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + createdAt: now, + updatedAt: now, + }); + enqueued += 1; + } + + await ctx.db.patch(args.jobId, { + totalItems: job.totalItems + enqueued, + queuedItems: job.queuedItems + enqueued, + cursor: page.continueCursor, + targetSelectionDone: page.isDone, + updatedAt: now, + }); + + return { enqueued, done: page.isDone, advanced: page.page.length > 0 }; + }, +}); + +export const claimPackageDryRunScanResultsInternal = internalMutation({ + args: { + jobId: v.id("packageDryRunScanJobs"), + batchSize: v.optional(v.number()), + }, + handler: async (ctx, args): Promise => { + let job = await ctx.db.get(args.jobId); + if (!job || job.status === "completed" || job.status === "failed") return []; + const now = Date.now(); + const requeued = await requeueStaleRunningResults(ctx, args.jobId, now); + if (requeued > 0) { + job = await ctx.db.get(args.jobId); + if (!job || job.status === "completed" || job.status === "failed") return []; + } + + const batchSize = normalizePositiveLimit( + args.batchSize ?? MAX_PROCESS_BATCH_SIZE, + MAX_PROCESS_BATCH_SIZE, + "batchSize", + ); + const queuedItems = await ctx.db + .query("packageDryRunScanResults") + .withIndex("by_job_status_created", (q) => q.eq("jobId", args.jobId).eq("status", "queued")) + .order("asc") + .take(batchSize); + if (queuedItems.length === 0) return []; + + const leaseExpiresAt = now + RUNNING_LEASE_MS; + const claimedItems: ClaimedItem[] = []; + for (const item of queuedItems) { + const claimToken = `${now}:${item._id}:${item.updatedAt}`; + await ctx.db.patch(item._id, { + status: "running", + startedAt: now, + claimToken, + leaseExpiresAt, + updatedAt: now, + }); + claimedItems.push({ + itemId: item._id, + releaseId: item.releaseId, + packageId: item.packageId, + packageName: item.packageName, + packageDisplayName: item.packageDisplayName, + version: item.version, + claimToken, + }); + } + + const staleRecheckAt = now + STALLED_JOB_RECHECK_MS; + const shouldScheduleStaleRecheck = (job.staleRecheckAt ?? 0) < leaseExpiresAt; + await ctx.db.patch(args.jobId, { + status: "running", + startedAt: job.startedAt ?? now, + queuedItems: Math.max(0, job.queuedItems - queuedItems.length), + runningItems: job.runningItems + queuedItems.length, + ...(shouldScheduleStaleRecheck ? { staleRecheckAt } : {}), + updatedAt: now, + }); + if (shouldScheduleStaleRecheck) { + await ctx.scheduler.runAfter( + STALLED_JOB_RECHECK_MS, + internalRefs.packageDryRunScans.processPackageDryRunScanJobBatchInternal as never, + { jobId: args.jobId } as never, + ); + } + + return claimedItems; + }, +}); + +export const getPackageDryRunScanInputInternal = internalQuery({ + args: { + releaseId: v.id("packageReleases"), + }, + handler: async (ctx, args): Promise => { + const release = await ctx.db.get(args.releaseId); + if (!release) return { kind: "skip", reason: "missing_release" }; + if (release.softDeletedAt) return { kind: "skip", reason: "soft_deleted_release" }; + + const pkg = await ctx.db.get(release.packageId); + if (!pkg) return { kind: "skip", reason: "missing_package" }; + if (pkg.softDeletedAt) return { kind: "skip", reason: "soft_deleted_package" }; + if (pkg.family === "skill") return { kind: "skip", reason: "unsupported_family" }; + + return { + kind: "scan", + files: release.files, + }; + }, +}); + +export const completePackageDryRunScanResultInternal = internalMutation({ + args: { + itemId: v.id("packageDryRunScanResults"), + claimToken: v.string(), + result: filesystemEvidenceValidator, + }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item || item.status !== "running" || item.claimToken !== args.claimToken) return; + const job = await ctx.db.get(item.jobId); + if (!job) return; + if (isTerminalDryRunJobStatus(job.status)) return; + + const now = Date.now(); + const wasRunning = item.status === "running"; + await ctx.db.patch(args.itemId, { + status: "completed", + rawFsUsageCount: args.result.rawFsUsage.totalCount, + fsSafeUsageCount: args.result.fsSafeUsage.totalCount, + findings: [...args.result.rawFsUsage.evidence, ...args.result.fsSafeUsage.evidence], + claimToken: undefined, + leaseExpiresAt: undefined, + completedAt: now, + updatedAt: now, + }); + + const matchedItems = filesystemMatched(args.result); + await ctx.db.patch(item.jobId, { + runningItems: Math.max(0, job.runningItems - (wasRunning ? 1 : 0)), + completedItems: job.completedItems + 1, + matchedItems: job.matchedItems + matchedItems, + updatedAt: now, + }); + }, +}); + +export const skipPackageDryRunScanResultInternal = internalMutation({ + args: { + itemId: v.id("packageDryRunScanResults"), + claimToken: v.string(), + reason: v.string(), + }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item || item.status !== "running" || item.claimToken !== args.claimToken) return; + const job = await ctx.db.get(item.jobId); + if (!job) return; + if (isTerminalDryRunJobStatus(job.status)) return; + + const now = Date.now(); + const wasRunning = item.status === "running"; + await ctx.db.patch(args.itemId, { + status: "skipped", + errors: [truncateDryRunErrorMessage(args.reason)], + claimToken: undefined, + leaseExpiresAt: undefined, + completedAt: now, + updatedAt: now, + }); + await ctx.db.patch(item.jobId, { + runningItems: Math.max(0, job.runningItems - (wasRunning ? 1 : 0)), + skippedItems: job.skippedItems + 1, + updatedAt: now, + }); + }, +}); + +export const failPackageDryRunScanResultInternal = internalMutation({ + args: { + itemId: v.id("packageDryRunScanResults"), + claimToken: v.string(), + error: v.string(), + }, + handler: async (ctx, args) => { + const item = await ctx.db.get(args.itemId); + if (!item || item.status !== "running" || item.claimToken !== args.claimToken) return; + const job = await ctx.db.get(item.jobId); + if (!job) return; + if (isTerminalDryRunJobStatus(job.status)) return; + + const now = Date.now(); + const wasRunning = item.status === "running"; + const error = truncateDryRunErrorMessage(args.error); + await ctx.db.patch(args.itemId, { + status: "failed", + errors: [error], + claimToken: undefined, + leaseExpiresAt: undefined, + completedAt: now, + updatedAt: now, + }); + await ctx.db.patch(item.jobId, { + runningItems: Math.max(0, job.runningItems - (wasRunning ? 1 : 0)), + failedItems: job.failedItems + 1, + updatedAt: now, + }); + }, +}); + +export const prunePackageDryRunScansInternal = internalMutation({ + args: { + jobBatchSize: v.optional(v.number()), + resultBatchSize: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const jobBatchSize = normalizePositiveLimit( + args.jobBatchSize ?? PRUNE_JOB_BATCH_SIZE, + PRUNE_JOB_BATCH_SIZE, + "jobBatchSize", + ); + const resultBatchSize = normalizePositiveLimit( + args.resultBatchSize ?? PRUNE_RESULT_BATCH_SIZE, + PRUNE_RESULT_BATCH_SIZE, + "resultBatchSize", + ); + const candidates: Array> = []; + for (const status of TERMINAL_JOB_STATUSES) { + const expiredJobs = await ctx.db + .query("packageDryRunScanJobs") + .withIndex("by_status_expires", (q) => q.eq("status", status).lte("expiresAt", now)) + .take(jobBatchSize + 1); + candidates.push(...expiredJobs); + } + const hasMoreExpiredJobs = candidates.length > jobBatchSize; + const jobs = candidates + .sort((left, right) => { + const expiresCompare = left.expiresAt - right.expiresAt; + if (expiresCompare !== 0) return expiresCompare; + return left._id.localeCompare(right._id); + }) + .slice(0, jobBatchSize); + + let jobsDeleted = 0; + let resultsDeleted = 0; + let remainingResultDeletes = resultBatchSize; + for (const job of jobs) { + if (remainingResultDeletes <= 0) break; + const resultDeleteLimit = remainingResultDeletes; + const results = await ctx.db + .query("packageDryRunScanResults") + .withIndex("by_job_created", (q) => q.eq("jobId", job._id)) + .take(resultDeleteLimit); + for (const result of results) { + await ctx.db.delete(result._id); + resultsDeleted += 1; + remainingResultDeletes -= 1; + } + if (results.length < resultDeleteLimit) { + await ctx.db.delete(job._id); + jobsDeleted += 1; + } + } + + if ( + (remainingResultDeletes <= 0 && jobs.length > jobsDeleted) || + (hasMoreExpiredJobs && jobsDeleted === jobs.length) + ) { + await ctx.scheduler.runAfter( + 0, + internalRefs.packageDryRunScans.prunePackageDryRunScansInternal as never, + { + jobBatchSize, + resultBatchSize, + } as never, + ); + } + + return { + jobsScanned: jobs.length, + jobsDeleted, + resultsDeleted, + }; + }, +}); + +async function requeueStaleRunningResults( + ctx: Pick, + jobId: Id<"packageDryRunScanJobs">, + now: number, +) { + const runningItems = await ctx.db + .query("packageDryRunScanResults") + .withIndex("by_job_status_lease", (q) => + q.eq("jobId", jobId).eq("status", "running").lte("leaseExpiresAt", now), + ) + .order("asc") + .take(MAX_PROCESS_BATCH_SIZE); + const staleItems = runningItems.filter((item) => (item.leaseExpiresAt ?? 0) <= now); + if (staleItems.length === 0) return 0; + + for (const item of staleItems) { + await ctx.db.patch(item._id, { + status: "queued", + claimToken: undefined, + leaseExpiresAt: undefined, + updatedAt: now, + }); + } + + const job = await ctx.db.get(jobId); + if (job) { + await ctx.db.patch(jobId, { + queuedItems: job.queuedItems + staleItems.length, + runningItems: Math.max(0, job.runningItems - staleItems.length), + updatedAt: now, + }); + } + return staleItems.length; +} + +export const finalizePackageDryRunScanJobInternal = internalMutation({ + args: { + jobId: v.id("packageDryRunScanJobs"), + }, + handler: async (ctx, args) => { + const job = await ctx.db.get(args.jobId); + if (!job) return { done: true as const, status: "missing" as const }; + if (isTerminalDryRunJobStatus(job.status)) { + return { done: true as const, status: job.status }; + } + + const done = + job.targetSelectionDone !== false && job.queuedItems === 0 && job.runningItems === 0; + if (!done) return { done: false as const, status: job.status }; + + const status = job.failedItems > 0 ? "failed" : "completed"; + const now = Date.now(); + await ctx.db.patch(args.jobId, { + status, + completedAt: now, + updatedAt: now, + expiresAt: now + JOB_RETENTION_MS, + staleRecheckAt: undefined, + error: status === "failed" ? "One or more dry-run scan items failed" : undefined, + }); + return { done: true as const, status }; + }, +}); + +export const failPackageDryRunScanJobInternal = internalMutation({ + args: { + jobId: v.id("packageDryRunScanJobs"), + error: v.string(), + }, + handler: async (ctx, args) => { + const job = await ctx.db.get(args.jobId); + if (!job) return { done: true as const, status: "missing" as const }; + if (isTerminalDryRunJobStatus(job.status)) { + return { done: true as const, status: job.status }; + } + + const now = Date.now(); + const outstandingItems = job.queuedItems + job.runningItems; + const error = truncateDryRunErrorMessage(args.error); + await ctx.db.patch(args.jobId, { + status: "failed", + queuedItems: 0, + runningItems: 0, + failedItems: job.failedItems + outstandingItems, + error, + completedAt: now, + updatedAt: now, + expiresAt: now + JOB_RETENTION_MS, + staleRecheckAt: undefined, + }); + return { done: true as const, status: "failed" as const }; + }, +}); + +export const processPackageDryRunScanJobBatchInternal = internalAction({ + args: { + jobId: v.id("packageDryRunScanJobs"), + batchSize: v.optional(v.number()), + }, + handler: async (ctx: ActionCtx, args) => { + let enqueued = { enqueued: 0, done: false, advanced: false }; + let claimed: ClaimedItem[] = []; + let completed = 0; + let skipped = 0; + let failed = 0; + + try { + enqueued = await runMutationRef<{ enqueued: number; done: boolean; advanced: boolean }>( + ctx, + internalRefs.packageDryRunScans.enqueuePackageDryRunScanJobTargetsInternal, + { jobId: args.jobId }, + ); + + claimed = await runMutationRef( + ctx, + internalRefs.packageDryRunScans.claimPackageDryRunScanResultsInternal, + { + jobId: args.jobId, + batchSize: args.batchSize, + }, + ); + + for (const item of claimed) { + try { + const input = await runQueryRef( + ctx, + internalRefs.packageDryRunScans.getPackageDryRunScanInputInternal, + { releaseId: item.releaseId }, + ); + if (input.kind === "skip") { + skipped += 1; + await runMutationRef( + ctx, + internalRefs.packageDryRunScans.skipPackageDryRunScanResultInternal, + { + itemId: item.itemId, + claimToken: item.claimToken, + reason: input.reason, + }, + ); + continue; + } + + const result = await runPackageDryRunFilesystemScan(ctx, { + files: input.files, + }); + completed += 1; + await runMutationRef( + ctx, + internalRefs.packageDryRunScans.completePackageDryRunScanResultInternal, + { + itemId: item.itemId, + claimToken: item.claimToken, + result, + }, + ); + } catch (error) { + failed += 1; + await runMutationRef( + ctx, + internalRefs.packageDryRunScans.failPackageDryRunScanResultInternal, + { + itemId: item.itemId, + claimToken: item.claimToken, + error: errorMessage(error), + }, + ); + } + } + + const finalized = await runMutationRef<{ done: boolean; status: string }>( + ctx, + internalRefs.packageDryRunScans.finalizePackageDryRunScanJobInternal, + { jobId: args.jobId }, + ); + + if (!finalized.done) { + const noProgress = enqueued.enqueued === 0 && claimed.length === 0; + const continuationDelay = + noProgress && (enqueued.done || !enqueued.advanced) ? STALLED_JOB_RECHECK_MS : 0; + await ctx.scheduler.runAfter( + continuationDelay, + internalRefs.packageDryRunScans.processPackageDryRunScanJobBatchInternal as never, + { + jobId: args.jobId, + batchSize: args.batchSize, + } as never, + ); + } + + return { + jobId: args.jobId, + enqueued: enqueued.enqueued, + claimed: claimed.length, + completed, + skipped, + failed, + done: finalized.done, + status: finalized.status, + }; + } catch (error) { + const finalized = await runMutationRef<{ done: boolean; status: string }>( + ctx, + internalRefs.packageDryRunScans.failPackageDryRunScanJobInternal, + { jobId: args.jobId, error: errorMessage(error) }, + ); + return { + jobId: args.jobId, + enqueued: enqueued.enqueued, + claimed: claimed.length, + completed, + skipped, + failed, + done: finalized.done, + status: finalized.status, + }; + } + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index df0630455b..1fed25d208 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1458,6 +1458,97 @@ const officialPluginMigrations = defineTable({ .index("by_phase_updatedAt", ["phase", "updatedAt"]) .index("by_updatedAt", ["updatedAt"]); +const packageDryRunScanJobs = defineTable({ + scanner: v.string(), + selector: v.union( + v.object({ + kind: v.literal("releaseIds"), + releaseIds: v.array(v.id("packageReleases")), + }), + v.object({ + kind: v.literal("packageNames"), + packageNames: v.array(v.string()), + }), + v.object({ + kind: v.literal("latestActive"), + limit: v.number(), + }), + v.object({ + kind: v.literal("allActive"), + }), + v.object({ + kind: v.literal("seededSample"), + seed: v.string(), + limit: v.number(), + maxCandidates: v.number(), + }), + ), + status: v.union( + v.literal("queued"), + v.literal("running"), + v.literal("completed"), + v.literal("failed"), + ), + requestedByUserId: v.id("users"), + totalItems: v.number(), + queuedItems: v.number(), + runningItems: v.number(), + completedItems: v.number(), + failedItems: v.number(), + skippedItems: v.number(), + matchedItems: v.number(), + cursor: v.optional(v.union(v.string(), v.null())), + targetSelectionDone: v.optional(v.boolean()), + candidateLimitReached: v.optional(v.boolean()), + error: v.optional(v.string()), + staleRecheckAt: v.optional(v.number()), + expiresAt: v.number(), + createdAt: v.number(), + updatedAt: v.number(), + startedAt: v.optional(v.number()), + completedAt: v.optional(v.number()), +}).index("by_status_expires", ["status", "expiresAt"]); + +const packageDryRunScanResults = defineTable({ + jobId: v.id("packageDryRunScanJobs"), + releaseId: v.id("packageReleases"), + packageId: v.id("packages"), + packageName: v.string(), + packageDisplayName: v.string(), + version: v.string(), + status: v.union( + v.literal("queued"), + v.literal("running"), + v.literal("completed"), + v.literal("failed"), + v.literal("skipped"), + ), + rawFsUsageCount: v.number(), + fsSafeUsageCount: v.number(), + findings: v.array( + v.object({ + code: v.string(), + severity: v.string(), + file: v.string(), + line: v.number(), + message: v.string(), + evidence: v.string(), + evidenceTruncated: v.boolean(), + }), + ), + errors: v.array(v.string()), + claimToken: v.optional(v.string()), + leaseExpiresAt: v.optional(v.number()), + createdAt: v.number(), + updatedAt: v.number(), + startedAt: v.optional(v.number()), + completedAt: v.optional(v.number()), +}) + .index("by_job_created", ["jobId", "createdAt"]) + .index("by_job_status_created", ["jobId", "status", "createdAt"]) + .index("by_job_status_lease", ["jobId", "status", "leaseExpiresAt"]) + .index("by_job_release", ["jobId", "releaseId"]); + const soulComments = defineTable({ soulId: v.id("souls"), userId: v.id("users"), @@ -1740,6 +1831,8 @@ export default defineSchema({ packageAppeals, packageModerationEventLogs, officialPluginMigrations, + packageDryRunScanJobs, + packageDryRunScanResults, soulComments, stars, soulStars, diff --git a/docs/http-api.md b/docs/http-api.md index 2134d38ead..46aa837d1e 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -11,7 +11,9 @@ Base URL: `https://clawhub.ai` (default). All v1 paths are under `/api/v1/...`. Legacy `/api/...` and `/api/cli/...` remain for compatibility (see `DEPRECATIONS.md`). -OpenAPI: `/api/v1/openapi.json`. +OpenAPI: `/api/v1/openapi.json` for the public skill API surface. Admin and +package-operator endpoints are documented here but are not included in that +public OpenAPI contract. ## Public catalog reuse @@ -694,6 +696,135 @@ Notes: - This tracks migration readiness only. It does not mutate OpenClaw or generate ClawPacks. +### `POST /api/v1/packages/-/dry-run-scans` + +Admin endpoint for starting an isolated package dry-run scan job. Results are +operational evidence only; they do not update package moderation state. + +Auth: + +- Requires an API token for an admin user. + +Request body: + +```json +{ + "selector": { + "kind": "seededSample", + "seed": "fs-safe-v1", + "limit": 25, + "maxCandidates": 1000 + } +} +``` + +Selectors: + +- `releaseIds`: `{ "kind": "releaseIds", "releaseIds": ["packageReleases:..."] }` +- `packageNames`: `{ "kind": "packageNames", "packageNames": ["@scope/plugin"] }` +- `latestActive`: `{ "kind": "latestActive", "limit": 100 }` selects active package + `latestReleaseId` releases by release creation time. +- `allActive`: `{ "kind": "allActive" }` +- `seededSample`: `{ "kind": "seededSample", "seed": "fs-safe-v1", "limit": 25, "maxCandidates": 1000 }` + +Limits: + +- `releaseIds` and `packageNames`: 1-200 entries; every explicit target must resolve. +- `latestActive.limit` and `seededSample.limit`: integer 1-200. +- `seededSample.maxCandidates`: integer 1-1000 and greater than or equal to `limit`. +- `allActive` does not accept sizing fields. + +Response: + +```json +{ + "jobId": "packageDryRunScanJobs:...", + "status": "queued", + "totalItems": 25, + "targetSelectionDone": true, + "candidateLimitReached": true +} +``` + +### `GET /api/v1/packages/-/dry-run-scans/{jobId}` + +Admin endpoint for reading dry-run scan job counters and selector metadata. + +Auth: + +- Requires an API token for an admin user. + +Response: + +```json +{ + "jobId": "packageDryRunScanJobs:...", + "scanner": "filesystem-safety-v1", + "selector": { "kind": "latestActive", "limit": 100 }, + "status": "running", + "totalItems": 100, + "queuedItems": 10, + "runningItems": 5, + "completedItems": 85, + "failedItems": 0, + "skippedItems": 0, + "matchedItems": 12, + "targetSelectionDone": true, + "candidateLimitReached": false, + "createdAt": 1760000000000, + "updatedAt": 1760000005000 +} +``` + +### `GET /api/v1/packages/-/dry-run-scans/{jobId}/results` + +Admin endpoint for paginating dry-run scan result rows. Results can be paged +while the job is still running. + +Auth: + +- Requires an API token for an admin user. + +Query params: + +- `limit` (optional): integer (1-500) +- `cursor` (optional): pagination cursor + +Response: + +```json +{ + "jobStatus": "running", + "jobDone": false, + "partial": true, + "items": [ + { + "itemId": "packageDryRunScanResults:...", + "jobId": "packageDryRunScanJobs:...", + "releaseId": "packageReleases:...", + "packageId": "packages:...", + "packageName": "@openclaw/example-plugin", + "packageDisplayName": "Example Plugin", + "version": "1.2.3", + "status": "completed", + "rawFsUsageCount": 2, + "fsSafeUsageCount": 1, + "findings": [], + "errors": [], + "createdAt": 1760000000000, + "updatedAt": 1760000005000 + } + ], + "nextCursor": null, + "done": true +} +``` + +`done` is pagination completion for the currently visible result set. Use +`jobDone`/`partial` to distinguish complete exports from partial reads. `partial` +is also `true` for failed broad-selection jobs whose target selection did not +finish. + ### `GET /api/v1/packages/moderation/queue` Moderator/admin endpoint for package release review queues. diff --git a/packages/clawhub-mod/README.md b/packages/clawhub-mod/README.md index fb6326987c..b52c24cd5c 100644 --- a/packages/clawhub-mod/README.md +++ b/packages/clawhub-mod/README.md @@ -130,6 +130,22 @@ clawhub-mod plugins backfill-artifacts [--all] [--apply] clawhub-mod plugins trusted-publisher get clawhub-mod plugins trusted-publisher set --repository --workflow-filename clawhub-mod plugins trusted-publisher delete + +clawhub-mod plugins dry-run-scan start --release-id [--release-id ...] +clawhub-mod plugins dry-run-scan start --package [--package ...] +clawhub-mod plugins dry-run-scan start --latest-active [--limit ] +clawhub-mod plugins dry-run-scan start --all-active +clawhub-mod plugins dry-run-scan start --seed [--limit ] [--max-candidates ] +clawhub-mod plugins dry-run-scan status +clawhub-mod plugins dry-run-scan watch +clawhub-mod plugins dry-run-scan export [--cursor ] [--limit ] [--allow-partial] [--json | --jsonl] ``` +Dry-run scan `export` reads terminal complete jobs by default; pass +`--allow-partial --json` to read incomplete results while a job is still running +or target selection failed before completion. `export --json` prints one result +page with `nextCursor`, plus `jobStatus`, `jobDone`, and `partial`; pass +`--cursor` to continue. `done` is page completion, not job completion. `export +--jsonl` streams all result pages for complete jobs. + All skill and plugin commands accept `--json` where the underlying endpoint supports machine-readable output. diff --git a/packages/clawhub-mod/src/cli.ts b/packages/clawhub-mod/src/cli.ts index 1150dade9b..f287afeb78 100644 --- a/packages/clawhub-mod/src/cli.ts +++ b/packages/clawhub-mod/src/cli.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { stat } from "node:fs/promises"; import { join, resolve } from "node:path"; -import { Command } from "commander"; +import { Command, InvalidArgumentError } from "commander"; import { resolveClawdbotDefaultWorkspace } from "../../clawhub/src/cli/clawdbotConfig.js"; import { cmdLoginFlow, cmdLogout, cmdWhoami } from "../../clawhub/src/cli/commands/auth.js"; import { @@ -28,15 +28,19 @@ import { cmdBanUser, cmdSetRole, cmdUnbanUser } from "./commands/moderation.js"; import { cmdBackfillPackageArtifacts, cmdDeletePackageTrustedPublisher, + cmdExportPackageDryRunScanResults, cmdListPackageAppeals, cmdListPackageMigrations, cmdListPackageReports, cmdModeratePackageRelease, + cmdPackageDryRunScanStatus, cmdPackageModerationQueue, cmdResolvePackageAppeal, cmdSetPackageTrustedPublisher, + cmdStartPackageDryRunScan, cmdTriagePackageReport, cmdUpsertPackageMigration, + cmdWatchPackageDryRunScan, } from "./commands/packages.js"; const program = new Command() @@ -382,6 +386,76 @@ function registerPluginGovernanceCommands(command: Command) { const opts = await resolveGlobalOpts(); await cmdDeletePackageTrustedPublisher(opts, name, options); }); + + const dryRunScan = command + .command("dry-run-scan") + .alias("dry-run-scans") + .description("Manage plugin dry-run scan jobs") + .showHelpAfterError() + .showSuggestionAfterError(); + + dryRunScan + .command("start") + .description("Start a plugin dry-run scan job") + .option("--release-id ", "Package release id to scan; repeatable", collectRepeated, []) + .option("--package ", "Package name to scan; repeatable", collectRepeated, []) + .option("--latest-active", "Scan latest active plugin releases") + .option("--all-active", "Scan active plugin releases") + .option("--seed ", "Scan a deterministic sample using this seed") + .option("--limit ", "Latest active or sample release limit (max 200)", parseDryRunInteger) + .option( + "--max-candidates ", + "Seeded sample candidate pool size (max 1000)", + parseDryRunInteger, + ) + .option("--json", "Output JSON") + .action(async (options) => { + const opts = await resolveGlobalOpts(); + await cmdStartPackageDryRunScan(opts, options); + }); + + dryRunScan + .command("status") + .description("Show a plugin dry-run scan job") + .argument("", "Dry-run scan job id") + .option("--json", "Output JSON") + .action(async (jobId, options) => { + const opts = await resolveGlobalOpts(); + await cmdPackageDryRunScanStatus(opts, jobId, options); + }); + + dryRunScan + .command("watch") + .description("Watch a plugin dry-run scan job until it completes or fails") + .argument("", "Dry-run scan job id") + .option("--interval-ms ", "Polling interval in milliseconds", parseDryRunInteger) + .option("--max-attempts ", "Maximum status checks before timing out", parseDryRunInteger) + .option("--json", "Output final JSON") + .action(async (jobId, options) => { + const opts = await resolveGlobalOpts(); + await cmdWatchPackageDryRunScan(opts, jobId, options); + }); + + dryRunScan + .command("export") + .description("Export plugin dry-run scan results") + .argument("", "Dry-run scan job id") + .option("--cursor ", "Resume cursor") + .option("--limit ", "Page size (max 500)", parseDryRunInteger) + .option("--allow-partial", "Export incomplete results before the scan is terminal") + .option("--json", "Output one JSON result page") + .option("--jsonl", "Output all results as JSONL") + .action(async (jobId, options) => { + const opts = await resolveGlobalOpts(); + await cmdExportPackageDryRunScanResults(opts, jobId, options); + }); +} + +function parseDryRunInteger(value: string) { + if (!/^-?\d+$/.test(value)) { + throw new InvalidArgumentError("must be an integer"); + } + return Number(value); } function registerPluginModerationCommands(command: Command) { @@ -563,6 +637,10 @@ function registerSkillModerationCommands(command: Command) { }); } +function collectRepeated(value: string, previous: string[]) { + return [...previous, value]; +} + program.action(() => { program.outputHelp(); process.exitCode = 0; diff --git a/packages/clawhub-mod/src/commands/packages.test.ts b/packages/clawhub-mod/src/commands/packages.test.ts new file mode 100644 index 0000000000..06f18d0226 --- /dev/null +++ b/packages/clawhub-mod/src/commands/packages.test.ts @@ -0,0 +1,760 @@ +/* @vitest-environment node */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createAuthTokenModuleMocks, + createHttpModuleMocks, + createRegistryModuleMocks, + createUiModuleMocks, + makeGlobalOpts, +} from "../../../clawhub/test/cliCommandTestKit.js"; + +const authTokenMocks = createAuthTokenModuleMocks(); +const registryMocks = createRegistryModuleMocks(); +const httpMocks = createHttpModuleMocks(); +const uiMocks = createUiModuleMocks(); + +vi.mock("../../../clawhub/src/cli/authToken.js", () => authTokenMocks.moduleFactory()); +vi.mock("../../../clawhub/src/cli/registry.js", () => registryMocks.moduleFactory()); +vi.mock("../../../clawhub/src/http.js", () => httpMocks.moduleFactory()); +vi.mock("../../../clawhub/src/cli/ui.js", () => uiMocks.moduleFactory()); + +const { + cmdExportPackageDryRunScanResults, + cmdPackageDryRunScanStatus, + cmdStartPackageDryRunScan, + cmdWatchPackageDryRunScan, +} = await import("./packages"); + +const mockLog = vi.spyOn(console, "log").mockImplementation(() => {}); +const mockWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + +afterEach(() => { + vi.useRealTimers(); + httpMocks.apiRequest.mockReset(); + vi.clearAllMocks(); +}); + +describe("package dry-run scan commands", () => { + it("starts a dry-run scan for explicit release ids with JSON output", async () => { + httpMocks.apiRequest.mockResolvedValueOnce({ + jobId: "packageDryRunScanJobs:1", + status: "queued", + totalItems: 1, + targetSelectionDone: true, + }); + + await cmdStartPackageDryRunScan(makeGlobalOpts(), { + releaseId: ["packageReleases:demo"], + json: true, + }); + + expect(httpMocks.apiRequest).toHaveBeenCalledWith( + "https://clawhub.ai", + { + method: "POST", + path: "/api/v1/packages/-/dry-run-scans", + token: "tkn", + body: { + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + }, + }, + expect.anything(), + ); + expect(mockWrite).toHaveBeenCalledWith( + `${JSON.stringify( + { + jobId: "packageDryRunScanJobs:1", + status: "queued", + totalItems: 1, + targetSelectionDone: true, + }, + null, + 2, + )}\n`, + ); + }); + + it("starts a dry-run scan for explicit package names", async () => { + httpMocks.apiRequest.mockResolvedValueOnce({ + jobId: "packageDryRunScanJobs:1", + status: "queued", + totalItems: 1, + targetSelectionDone: true, + }); + + await cmdStartPackageDryRunScan(makeGlobalOpts(), { + package: ["demo-plugin"], + json: true, + }); + + expect(httpMocks.apiRequest).toHaveBeenCalledWith( + "https://clawhub.ai", + expect.objectContaining({ + method: "POST", + path: "/api/v1/packages/-/dry-run-scans", + token: "tkn", + body: { + selector: { kind: "packageNames", packageNames: ["demo-plugin"] }, + }, + }), + expect.anything(), + ); + }); + + it("prints pending target selection for all-active dry-run scans", async () => { + httpMocks.apiRequest.mockResolvedValueOnce({ + jobId: "packageDryRunScanJobs:1", + status: "queued", + totalItems: 0, + targetSelectionDone: false, + }); + + await cmdStartPackageDryRunScan(makeGlobalOpts(), { + allActive: true, + }); + + expect(mockLog).toHaveBeenCalledWith( + "Started dry-run scan packageDryRunScanJobs:1: queued, target selection pending.", + ); + }); + + it("prints candidate-limit warnings when starting dry-run scans", async () => { + httpMocks.apiRequest.mockResolvedValueOnce({ + jobId: "packageDryRunScanJobs:1", + status: "queued", + totalItems: 1, + targetSelectionDone: true, + candidateLimitReached: true, + }); + + await cmdStartPackageDryRunScan(makeGlobalOpts(), { + seed: "fs-safe-v1", + }); + + expect(mockLog).toHaveBeenCalledWith( + "Started dry-run scan packageDryRunScanJobs:1: queued, 1 items.", + ); + expect(mockLog).toHaveBeenCalledWith( + "Warning: maxCandidates was reached before the full candidate set.", + ); + }); + + it("rejects invalid dry-run scan numeric options", async () => { + await expect( + cmdStartPackageDryRunScan(makeGlobalOpts(), { + seed: "fs-safe-v1", + limit: Number.NaN, + }), + ).rejects.toThrow("--limit must be a positive integer"); + await expect( + cmdStartPackageDryRunScan(makeGlobalOpts(), { + seed: "fs-safe-v1", + maxCandidates: 0, + }), + ).rejects.toThrow("--max-candidates must be a positive integer"); + expect(httpMocks.apiRequest).not.toHaveBeenCalled(); + }); + + it("rejects dry-run scan numeric options above API limits", async () => { + await expect( + cmdStartPackageDryRunScan(makeGlobalOpts(), { + latestActive: true, + limit: 201, + }), + ).rejects.toThrow("--limit must be at most 200"); + await expect( + cmdStartPackageDryRunScan(makeGlobalOpts(), { + seed: "fs-safe-v1", + maxCandidates: 1_001, + }), + ).rejects.toThrow("--max-candidates must be at most 1000"); + await expect( + cmdStartPackageDryRunScan(makeGlobalOpts(), { + seed: "fs-safe-v1", + limit: 20, + maxCandidates: 10, + }), + ).rejects.toThrow("--max-candidates must be greater than or equal to --limit"); + await expect( + cmdStartPackageDryRunScan(makeGlobalOpts(), { + seed: "x".repeat(129), + }), + ).rejects.toThrow("--seed is limited to 128 characters"); + expect(httpMocks.apiRequest).not.toHaveBeenCalled(); + }); + + it("rejects dry-run scan explicit selectors above API limits", async () => { + await expect( + cmdStartPackageDryRunScan(makeGlobalOpts(), { + releaseId: Array.from({ length: 201 }, (_, index) => `packageReleases:${index}`), + }), + ).rejects.toThrow("--release-id is limited to 200 releases"); + await expect( + cmdStartPackageDryRunScan(makeGlobalOpts(), { + package: Array.from({ length: 201 }, (_, index) => `plugin-${index}`), + }), + ).rejects.toThrow("--package is limited to 200 packages"); + expect(httpMocks.apiRequest).not.toHaveBeenCalled(); + }); + + it("rejects blank dry-run scan explicit selectors", async () => { + await expect( + cmdStartPackageDryRunScan(makeGlobalOpts(), { + releaseId: [" "], + }), + ).rejects.toThrow("--release-id cannot be blank"); + await expect( + cmdStartPackageDryRunScan(makeGlobalOpts(), { + package: [" "], + }), + ).rejects.toThrow("--package cannot be blank"); + await expect( + cmdStartPackageDryRunScan(makeGlobalOpts(), { + seed: " ", + }), + ).rejects.toThrow("--seed cannot be blank"); + expect(httpMocks.apiRequest).not.toHaveBeenCalled(); + }); + + it("rejects dry-run scan sizing options that do not apply to the selected mode", async () => { + await expect( + cmdStartPackageDryRunScan(makeGlobalOpts(), { + allActive: true, + limit: 10, + }), + ).rejects.toThrow("--limit can only be used with --latest-active or --seed"); + await expect( + cmdStartPackageDryRunScan(makeGlobalOpts(), { + latestActive: true, + maxCandidates: 50, + }), + ).rejects.toThrow("--max-candidates can only be used with --seed"); + await expect( + cmdStartPackageDryRunScan(makeGlobalOpts(), { + releaseId: ["packageReleases:demo"], + maxCandidates: 50, + }), + ).rejects.toThrow("--max-candidates can only be used with --seed"); + expect(httpMocks.apiRequest).not.toHaveBeenCalled(); + }); + + it("reads dry-run scan status with JSON output", async () => { + httpMocks.apiRequest.mockResolvedValueOnce({ + jobId: "packageDryRunScanJobs:1", + scanner: "filesystem-safety-v1", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + status: "completed", + totalItems: 1, + queuedItems: 0, + runningItems: 0, + completedItems: 1, + failedItems: 0, + skippedItems: 0, + matchedItems: 0, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_100, + }); + + await cmdPackageDryRunScanStatus(makeGlobalOpts(), "packageDryRunScanJobs:1", { json: true }); + + expect(httpMocks.apiRequest).toHaveBeenCalledWith( + "https://clawhub.ai", + { + method: "GET", + path: "/api/v1/packages/-/dry-run-scans/packageDryRunScanJobs%3A1", + token: "tkn", + }, + expect.anything(), + ); + expect(mockWrite).toHaveBeenCalledWith(expect.stringContaining('"status": "completed"')); + }); + + it("prints matched item count in human-readable dry-run scan status", async () => { + httpMocks.apiRequest.mockResolvedValueOnce({ + ...makeDryRunScanJob("completed"), + jobId: "packageDryRunScanJobs:1\n\u001b[31m", + matchedItems: 2, + error: "worker failed\n\u001b[31mboom\u202e", + }); + + await cmdPackageDryRunScanStatus(makeGlobalOpts(), "packageDryRunScanJobs:1", {}); + + expect(mockLog).toHaveBeenCalledWith( + "Dry-run scan packageDryRunScanJobs:1\\n\\x1b[31m: completed", + ); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("matched:2")); + expect(mockLog).toHaveBeenCalledWith(" error: worker failed\\n\\x1b[31mboom\\u202e"); + }); + + it("watches dry-run scan status until completion", async () => { + vi.useFakeTimers(); + httpMocks.apiRequest + .mockResolvedValueOnce(makeDryRunScanJob("running")) + .mockResolvedValueOnce(makeDryRunScanJob("completed")); + + const watch = cmdWatchPackageDryRunScan(makeGlobalOpts(), "packageDryRunScanJobs:1", { + intervalMs: 10, + maxAttempts: 3, + json: true, + }); + + await vi.waitFor(() => expect(httpMocks.apiRequest).toHaveBeenCalledTimes(1)); + await vi.advanceTimersByTimeAsync(10); + await watch; + + expect(httpMocks.apiRequest).toHaveBeenCalledTimes(2); + expect(httpMocks.apiRequest).toHaveBeenNthCalledWith( + 2, + "https://clawhub.ai", + { + method: "GET", + path: "/api/v1/packages/-/dry-run-scans/packageDryRunScanJobs%3A1", + token: "tkn", + }, + expect.anything(), + ); + expect(mockWrite).toHaveBeenCalledWith(expect.stringContaining('"status": "completed"')); + }); + + it("stops watching when a dry-run scan fails", async () => { + httpMocks.apiRequest.mockResolvedValueOnce(makeDryRunScanJob("failed")); + + await cmdWatchPackageDryRunScan(makeGlobalOpts(), "packageDryRunScanJobs:1", { + intervalMs: 10, + maxAttempts: 3, + json: true, + }); + + expect(httpMocks.apiRequest).toHaveBeenCalledTimes(1); + expect(mockWrite).toHaveBeenCalledWith(expect.stringContaining('"status": "failed"')); + }); + + it("bounds dry-run scan watch polling attempts", async () => { + vi.useFakeTimers(); + httpMocks.apiRequest + .mockResolvedValueOnce(makeDryRunScanJob("running")) + .mockResolvedValueOnce(makeDryRunScanJob("running")); + + const watch = cmdWatchPackageDryRunScan(makeGlobalOpts(), "packageDryRunScanJobs:1", { + intervalMs: 10, + maxAttempts: 2, + }); + const watchError = expect(watch).rejects.toThrow( + "Dry-run scan watch timed out after 2 status checks", + ); + + await vi.waitFor(() => expect(httpMocks.apiRequest).toHaveBeenCalledTimes(1)); + await vi.advanceTimersByTimeAsync(10); + await watchError; + expect(httpMocks.apiRequest).toHaveBeenCalledTimes(2); + }); + + it("marks watch summaries with pending target selection", async () => { + vi.useFakeTimers(); + httpMocks.apiRequest.mockResolvedValueOnce({ + ...makeDryRunScanJob("running"), + jobId: "packageDryRunScanJobs:1\n\u001b[31m", + totalItems: 0, + runningItems: 0, + targetSelectionDone: false, + }); + + const watch = cmdWatchPackageDryRunScan(makeGlobalOpts(), "packageDryRunScanJobs:1", { + intervalMs: 10, + maxAttempts: 1, + }); + const watchError = expect(watch).rejects.toThrow( + "Dry-run scan watch timed out after 1 status checks", + ); + + await vi.waitFor(() => expect(httpMocks.apiRequest).toHaveBeenCalledTimes(1)); + await watchError; + + expect(uiMocks.spinner.text).toContain("target selection pending"); + expect(uiMocks.spinner.text).toContain("packageDryRunScanJobs:1\\n\\x1b[31m"); + }); + + it("exports dry-run scan results as JSONL", async () => { + httpMocks.apiRequest + .mockResolvedValueOnce(makeDryRunScanJob("completed")) + .mockResolvedValueOnce({ + jobStatus: "completed", + jobDone: true, + partial: false, + items: [ + { + itemId: "packageDryRunScanResults:1", + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "completed", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_100, + }, + ], + nextCursor: "cursor-2", + done: false, + }) + .mockResolvedValueOnce({ + jobStatus: "completed", + jobDone: true, + partial: false, + items: [], + nextCursor: null, + done: true, + }); + + await cmdExportPackageDryRunScanResults(makeGlobalOpts(), "packageDryRunScanJobs:1", { + jsonl: true, + limit: 1, + }); + + expect(httpMocks.apiRequest).toHaveBeenNthCalledWith( + 2, + "https://clawhub.ai", + expect.objectContaining({ + method: "GET", + token: "tkn", + url: expect.stringContaining( + "/api/v1/packages/-/dry-run-scans/packageDryRunScanJobs%3A1/results?", + ), + }), + expect.anything(), + ); + expect(mockWrite).toHaveBeenCalledWith( + `${JSON.stringify({ + itemId: "packageDryRunScanResults:1", + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "completed", + rawFsUsageCount: 0, + fsSafeUsageCount: 0, + findings: [], + errors: [], + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_100, + })}\n`, + ); + expect(mockLog).not.toHaveBeenCalled(); + }); + + it("escapes bidi controls in JSONL dry-run scan exports", async () => { + const item = { + itemId: "packageDryRunScanResults:1", + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo\u202e-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "completed", + rawFsUsageCount: 1, + fsSafeUsageCount: 0, + findings: [ + { + code: "raw-fs-usage", + severity: "medium", + file: "src/index.ts", + line: 12, + message: "Raw filesystem API usage detected", + evidence: "fs.readFileSync(path)\u202e", + evidenceTruncated: false, + }, + ], + errors: [], + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_100, + }; + httpMocks.apiRequest + .mockResolvedValueOnce(makeDryRunScanJob("completed")) + .mockResolvedValueOnce({ + jobStatus: "completed", + jobDone: true, + partial: false, + items: [item], + nextCursor: null, + done: true, + }); + + await cmdExportPackageDryRunScanResults(makeGlobalOpts(), "packageDryRunScanJobs:1", { + jsonl: true, + }); + + const output = mockWrite.mock.calls.map(([chunk]) => String(chunk)).join(""); + expect(output).toContain("demo\\u202e-plugin"); + expect(output).toContain("fs.readFileSync(path)\\u202e"); + expect(JSON.parse(output)).toEqual(item); + }); + + it("exports dry-run scan results as JSON when they fit in one page", async () => { + const item = { + itemId: "packageDryRunScanResults:1", + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "completed", + rawFsUsageCount: 1, + fsSafeUsageCount: 0, + findings: [], + errors: [], + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_100, + }; + httpMocks.apiRequest + .mockResolvedValueOnce(makeDryRunScanJob("completed")) + .mockResolvedValueOnce({ + jobStatus: "completed", + jobDone: true, + partial: false, + items: [item], + nextCursor: null, + done: true, + }); + + await cmdExportPackageDryRunScanResults(makeGlobalOpts(), "packageDryRunScanJobs:1", { + json: true, + limit: 1, + }); + + const output = mockWrite.mock.calls.map(([chunk]) => String(chunk)).join(""); + expect(JSON.parse(output)).toEqual({ + items: [item], + jobStatus: "completed", + jobDone: true, + partial: false, + nextCursor: null, + done: true, + }); + expect(httpMocks.apiRequest).toHaveBeenCalledTimes(2); + expect(mockLog).not.toHaveBeenCalled(); + }); + + it("returns next cursor for JSON exports that do not fit in one page", async () => { + httpMocks.apiRequest + .mockResolvedValueOnce(makeDryRunScanJob("completed")) + .mockResolvedValueOnce({ + jobStatus: "completed", + jobDone: true, + partial: false, + items: [ + { + itemId: "packageDryRunScanResults:1", + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "completed", + rawFsUsageCount: 1, + fsSafeUsageCount: 0, + findings: [], + errors: [], + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_100, + }, + ], + nextCursor: "cursor-2", + done: false, + }); + + await cmdExportPackageDryRunScanResults(makeGlobalOpts(), "packageDryRunScanJobs:1", { + json: true, + limit: 1, + }); + + expect(httpMocks.apiRequest).toHaveBeenCalledTimes(2); + const output = mockWrite.mock.calls.map(([chunk]) => String(chunk)).join(""); + expect(JSON.parse(output)).toMatchObject({ nextCursor: "cursor-2", done: false }); + }); + + it("prints dry-run scan finding evidence in human-readable exports", async () => { + httpMocks.apiRequest + .mockResolvedValueOnce(makeDryRunScanJob("completed")) + .mockResolvedValueOnce({ + items: [ + { + itemId: "packageDryRunScanResults:1", + jobId: "packageDryRunScanJobs:1", + releaseId: "packageReleases:demo", + packageId: "packages:demo", + packageName: "demo-plugin", + packageDisplayName: "Demo Plugin", + version: "1.0.0", + status: "completed", + rawFsUsageCount: 1, + fsSafeUsageCount: 0, + findings: [ + { + code: "raw-fs-usage\u001b[0m", + severity: "medium\u001b[31m", + file: "src/\u001b]8;;https://example.invalid\u0007index.ts", + line: 12, + message: "Raw filesystem API usage detected\u001b[31m", + evidence: "fs.readFileSync(path)\n\u001b[2J\u202e", + evidenceTruncated: false, + }, + ], + errors: ["worker warning\u001b[0m"], + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_100, + }, + ], + nextCursor: null, + done: true, + }); + + await cmdExportPackageDryRunScanResults(makeGlobalOpts(), "packageDryRunScanJobs:1", {}); + + expect(mockLog).toHaveBeenCalledWith( + "demo-plugin@1.0.0 completed raw-fs:1 fs-safe:0 findings:1", + ); + expect(mockLog).toHaveBeenCalledWith( + " medium\\x1b[31m raw-fs-usage\\x1b[0m src/\\x1b]8;;https://example.invalid\\x07index.ts:12: Raw filesystem API usage detected\\x1b[31m", + ); + expect(mockLog).toHaveBeenCalledWith(" evidence: fs.readFileSync(path)\\n\\x1b[2J\\u202e"); + expect(mockLog).toHaveBeenCalledWith(" error: worker warning\\x1b[0m"); + }); + + it("rejects partial dry-run scan exports without explicit opt-in", async () => { + httpMocks.apiRequest.mockResolvedValueOnce(makeDryRunScanJob("running")); + + await expect( + cmdExportPackageDryRunScanResults(makeGlobalOpts(), "packageDryRunScanJobs:1\u001b[31m", { + json: true, + }), + ).rejects.toThrow( + "Dry-run scan packageDryRunScanJobs:1\\x1b[31m is running; use --allow-partial to export incomplete results", + ); + + expect(httpMocks.apiRequest).toHaveBeenCalledTimes(1); + expect(mockWrite).not.toHaveBeenCalled(); + }); + + it("rejects terminal dry-run scan exports when target selection did not finish", async () => { + httpMocks.apiRequest.mockResolvedValueOnce({ + ...makeDryRunScanJob("failed"), + selector: { kind: "allActive" }, + targetSelectionDone: false, + }); + + await expect( + cmdExportPackageDryRunScanResults(makeGlobalOpts(), "packageDryRunScanJobs:1", { + json: true, + }), + ).rejects.toThrow( + "Dry-run scan packageDryRunScanJobs:1 is failed; use --allow-partial to export incomplete results", + ); + + expect(httpMocks.apiRequest).toHaveBeenCalledTimes(1); + expect(mockWrite).not.toHaveBeenCalled(); + }); + + it("exports partial dry-run scan results when explicitly requested", async () => { + httpMocks.apiRequest.mockResolvedValueOnce(makeDryRunScanJob("running")).mockResolvedValueOnce({ + jobStatus: "running", + jobDone: false, + partial: true, + items: [], + nextCursor: null, + done: true, + }); + + await cmdExportPackageDryRunScanResults(makeGlobalOpts(), "packageDryRunScanJobs:1", { + json: true, + allowPartial: true, + }); + + expect(httpMocks.apiRequest).toHaveBeenCalledTimes(2); + const output = mockWrite.mock.calls.map(([chunk]) => String(chunk)).join(""); + expect(JSON.parse(output)).toEqual({ + jobStatus: "running", + jobDone: false, + partial: true, + items: [], + nextCursor: null, + done: true, + }); + }); + + it("allows --allow-partial with JSONL when the dry-run scan is complete", async () => { + httpMocks.apiRequest + .mockResolvedValueOnce(makeDryRunScanJob("completed")) + .mockResolvedValueOnce({ + jobStatus: "completed", + jobDone: true, + partial: false, + items: [], + nextCursor: null, + done: true, + }); + + await cmdExportPackageDryRunScanResults(makeGlobalOpts(), "packageDryRunScanJobs:1", { + jsonl: true, + allowPartial: true, + }); + + expect(httpMocks.apiRequest).toHaveBeenCalledTimes(2); + expect(mockWrite).not.toHaveBeenCalled(); + }); + + it("requires JSON output for partial dry-run scan exports", async () => { + httpMocks.apiRequest.mockResolvedValueOnce(makeDryRunScanJob("running")); + + await expect( + cmdExportPackageDryRunScanResults(makeGlobalOpts(), "packageDryRunScanJobs:1", { + allowPartial: true, + jsonl: true, + }), + ).rejects.toThrow( + "Partial dry-run scan exports require --json so job completion metadata is preserved", + ); + + expect(httpMocks.apiRequest).toHaveBeenCalledTimes(1); + expect(mockWrite).not.toHaveBeenCalled(); + }); + + it("rejects mutually exclusive dry-run scan export output modes", async () => { + await expect( + cmdExportPackageDryRunScanResults(makeGlobalOpts(), "packageDryRunScanJobs:1", { + json: true, + jsonl: true, + }), + ).rejects.toThrow("Use only one of --json or --jsonl"); + expect(httpMocks.apiRequest).not.toHaveBeenCalled(); + }); +}); + +function makeDryRunScanJob(status: "queued" | "running" | "completed" | "failed") { + return { + jobId: "packageDryRunScanJobs:1", + scanner: "filesystem-safety-v1", + selector: { kind: "releaseIds", releaseIds: ["packageReleases:demo"] }, + status, + totalItems: 1, + queuedItems: status === "queued" ? 1 : 0, + runningItems: status === "running" ? 1 : 0, + completedItems: status === "completed" ? 1 : 0, + failedItems: status === "failed" ? 1 : 0, + skippedItems: 0, + matchedItems: 0, + targetSelectionDone: true, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_100, + }; +} diff --git a/packages/clawhub-mod/src/commands/packages.ts b/packages/clawhub-mod/src/commands/packages.ts index e3684f0e5c..513055d341 100644 --- a/packages/clawhub-mod/src/commands/packages.ts +++ b/packages/clawhub-mod/src/commands/packages.ts @@ -6,13 +6,21 @@ import { } from "../../../clawhub/src/cli/commands/moderationPlan.js"; import { getRegistry } from "../../../clawhub/src/cli/registry.js"; import type { GlobalOpts } from "../../../clawhub/src/cli/types.js"; -import { createSpinner, fail, formatError } from "../../../clawhub/src/cli/ui.js"; +import { + createSpinner, + escapeTerminalControlCharacters, + fail, + formatError, +} from "../../../clawhub/src/cli/ui.js"; import { apiRequest, registryUrl } from "../../../clawhub/src/http.js"; import { ApiRoutes, ApiV1PackageArtifactBackfillResponseSchema, ApiV1PackageAppealListResponseSchema, ApiV1PackageAppealResolveResponseSchema, + ApiV1PackageDryRunScanJobResponseSchema, + ApiV1PackageDryRunScanResultsResponseSchema, + ApiV1PackageDryRunScanStartResponseSchema, ApiV1PackageModerationQueueResponseSchema, ApiV1PackageOfficialMigrationListResponseSchema, ApiV1PackageOfficialMigrationResponseSchema, @@ -23,6 +31,9 @@ import { type PackageAppealFinalAction, type PackageAppealListStatus, type PackageAppealStatus, + type ApiV1PackageDryRunScanJobResponse, + type PackageDryRunScanSelector, + type PackageDryRunScanResultItem, type PackageModerationQueueStatus, type PackageOfficialMigrationListPhase, type PackageReportFinalAction, @@ -97,6 +108,34 @@ type PackageBackfillArtifactsOptions = { json?: boolean; }; +type PackageDryRunScanStartOptions = { + releaseId?: string[]; + package?: string[]; + latestActive?: boolean; + allActive?: boolean; + seed?: string; + limit?: number; + maxCandidates?: number; + json?: boolean; +}; + +type PackageDryRunScanStatusOptions = { + json?: boolean; +}; + +type PackageDryRunScanWatchOptions = PackageDryRunScanStatusOptions & { + intervalMs?: number; + maxAttempts?: number; +}; + +type PackageDryRunScanExportOptions = { + cursor?: string; + limit?: number; + allowPartial?: boolean; + json?: boolean; + jsonl?: boolean; +}; + type PackageMigrationListOptions = { phase?: PackageOfficialMigrationListPhase; cursor?: string; @@ -120,6 +159,13 @@ type PackageMigrationUpsertOptions = { json?: boolean; }; +const DEFAULT_DRY_RUN_SCAN_WATCH_INTERVAL_MS = 2_000; +const DEFAULT_DRY_RUN_SCAN_WATCH_MAX_ATTEMPTS = 150; +const PACKAGE_DRY_RUN_SCAN_MAX_EXPLICIT_SELECTORS = 200; +const PACKAGE_DRY_RUN_SCAN_MAX_LIMIT = 200; +const PACKAGE_DRY_RUN_SCAN_MAX_CANDIDATES = 1_000; +const PACKAGE_DRY_RUN_SCAN_MAX_SEED_CHARS = 128; + export async function cmdSetPackageTrustedPublisher( opts: GlobalOpts, packageName: string, @@ -570,6 +616,383 @@ export async function cmdBackfillPackageArtifacts( } } +export async function cmdStartPackageDryRunScan( + opts: GlobalOpts, + options: PackageDryRunScanStartOptions = {}, +) { + const releaseIdValues = options.releaseId ?? []; + const packageValues = options.package ?? []; + const releaseIds = releaseIdValues.map((id) => id.trim()); + const packageNames = packageValues.map((name) => name.trim()); + const seed = options.seed?.trim(); + if (releaseIds.some((id) => id.length === 0)) { + fail("--release-id cannot be blank"); + } + if (packageNames.some((name) => name.length === 0)) { + fail("--package cannot be blank"); + } + if (options.seed !== undefined && seed?.length === 0) { + fail("--seed cannot be blank"); + } + const modeCount = [ + releaseIds.length > 0, + packageNames.length > 0, + options.latestActive === true, + options.allActive === true, + Boolean(seed), + ].filter(Boolean).length; + if (modeCount !== 1) { + fail("Use exactly one of --release-id, --package, --latest-active, --all-active, or --seed"); + } + rejectUnusedDryRunScanSizingOptions(options, seed); + + let selector: PackageDryRunScanSelector; + if (releaseIds.length > 0) { + if (releaseIds.length > PACKAGE_DRY_RUN_SCAN_MAX_EXPLICIT_SELECTORS) { + fail(`--release-id is limited to ${PACKAGE_DRY_RUN_SCAN_MAX_EXPLICIT_SELECTORS} releases`); + } + selector = { kind: "releaseIds", releaseIds }; + } else if (packageNames.length > 0) { + if (packageNames.length > PACKAGE_DRY_RUN_SCAN_MAX_EXPLICIT_SELECTORS) { + fail(`--package is limited to ${PACKAGE_DRY_RUN_SCAN_MAX_EXPLICIT_SELECTORS} packages`); + } + selector = { kind: "packageNames", packageNames }; + } else if (seed) { + if (seed.length > PACKAGE_DRY_RUN_SCAN_MAX_SEED_CHARS) { + fail(`--seed is limited to ${PACKAGE_DRY_RUN_SCAN_MAX_SEED_CHARS} characters`); + } + const limit = requirePositiveBoundedInteger( + options.limit, + 100, + PACKAGE_DRY_RUN_SCAN_MAX_LIMIT, + "--limit", + ); + const maxCandidates = requirePositiveBoundedInteger( + options.maxCandidates, + 1_000, + PACKAGE_DRY_RUN_SCAN_MAX_CANDIDATES, + "--max-candidates", + ); + if (maxCandidates < limit) { + fail("--max-candidates must be greater than or equal to --limit"); + } + selector = { + kind: "seededSample", + seed, + limit, + maxCandidates, + }; + } else if (options.allActive) { + selector = { kind: "allActive" }; + } else { + selector = { + kind: "latestActive", + limit: requirePositiveBoundedInteger( + options.limit, + 100, + PACKAGE_DRY_RUN_SCAN_MAX_LIMIT, + "--limit", + ), + }; + } + + const token = await requireAuthToken(); + const registry = await getRegistry(opts, { cache: true }); + const spinner = options.json ? null : createSpinner("Starting dry-run scan"); + try { + const result = await apiRequest( + registry, + { + method: "POST", + path: `${ApiRoutes.packages}/-/dry-run-scans`, + token, + body: { selector }, + }, + ApiV1PackageDryRunScanStartResponseSchema, + ); + spinner?.stop(); + if (options.json) { + process.stdout.write(`${stringifyTerminalSafeJson(result, 2)}\n`); + return; + } + if (!result.targetSelectionDone) { + console.log( + `Started dry-run scan ${escapeTerminalControlCharacters(result.jobId)}: ${result.status}, target selection pending.`, + ); + return; + } + console.log( + `Started dry-run scan ${escapeTerminalControlCharacters(result.jobId)}: ${result.status}, ${result.totalItems} items.`, + ); + if (result.candidateLimitReached) { + console.log("Warning: maxCandidates was reached before the full candidate set."); + } + } catch (error) { + spinner?.fail(formatError(error)); + throw error; + } +} + +export async function cmdPackageDryRunScanStatus( + opts: GlobalOpts, + jobId: string, + options: PackageDryRunScanStatusOptions = {}, +) { + const trimmed = normalizeJobIdOrFail(jobId); + const token = await requireAuthToken(); + const registry = await getRegistry(opts, { cache: true }); + const result = await fetchPackageDryRunScanJob(registry, token, trimmed); + + if (options.json) { + process.stdout.write(`${stringifyTerminalSafeJson(result, 2)}\n`); + return; + } + + printPackageDryRunScanJob(result); +} + +export async function cmdWatchPackageDryRunScan( + opts: GlobalOpts, + jobId: string, + options: PackageDryRunScanWatchOptions = {}, +) { + const trimmed = normalizeJobIdOrFail(jobId); + const intervalMs = normalizePositiveInteger( + options.intervalMs, + DEFAULT_DRY_RUN_SCAN_WATCH_INTERVAL_MS, + "--interval-ms", + ); + const maxAttempts = normalizePositiveInteger( + options.maxAttempts, + DEFAULT_DRY_RUN_SCAN_WATCH_MAX_ATTEMPTS, + "--max-attempts", + ); + const token = await requireAuthToken(); + const registry = await getRegistry(opts, { cache: true }); + const spinner = options.json + ? null + : createSpinner(`Watching dry-run scan ${escapeTerminalControlCharacters(trimmed)}`); + + try { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const result = await fetchPackageDryRunScanJob(registry, token, trimmed); + if (spinner) spinner.text = formatPackageDryRunScanJobSummary(result); + + if (isPackageDryRunScanTerminal(result)) { + spinner?.stop(); + if (options.json) { + process.stdout.write(`${stringifyTerminalSafeJson(result, 2)}\n`); + return; + } + printPackageDryRunScanJob(result); + return; + } + + if (attempt < maxAttempts) await sleep(intervalMs); + } + } catch (error) { + spinner?.fail(formatError(error)); + throw error; + } + + const message = `Dry-run scan watch timed out after ${maxAttempts} status checks`; + spinner?.fail(message); + fail(message); +} + +export async function cmdExportPackageDryRunScanResults( + opts: GlobalOpts, + jobId: string, + options: PackageDryRunScanExportOptions = {}, +) { + if (options.json && options.jsonl) fail("Use only one of --json or --jsonl"); + + const trimmed = normalizeJobIdOrFail(jobId); + const token = await requireAuthToken(); + const registry = await getRegistry(opts, { cache: true }); + const job = await fetchPackageDryRunScanJob(registry, token, trimmed); + const exportWouldBePartial = !isPackageDryRunScanTerminal(job) || !job.targetSelectionDone; + if (exportWouldBePartial && !options.allowPartial) { + fail( + `Dry-run scan ${escapeTerminalControlCharacters(trimmed)} is ${job.status}; use --allow-partial to export incomplete results`, + ); + } + if (exportWouldBePartial && options.allowPartial && !options.json) { + fail("Partial dry-run scan exports require --json so job completion metadata is preserved"); + } + const limit = requirePositiveBoundedInteger(options.limit, 100, 500, "--limit"); + let cursor = options.cursor?.trim() || null; + let wroteAny = false; + let done = false; + let jobStatus = job.status; + let jobDone = isPackageDryRunScanTerminal(job); + let partial = !jobDone; + const jsonItems: PackageDryRunScanResultItem[] = []; + + do { + const url = registryUrl( + `${ApiRoutes.packages}/-/dry-run-scans/${encodeURIComponent(trimmed)}/results`, + registry, + ); + url.searchParams.set("limit", String(limit)); + if (cursor) url.searchParams.set("cursor", cursor); + + const result = await apiRequest( + registry, + { + method: "GET", + url: url.toString(), + token, + }, + ApiV1PackageDryRunScanResultsResponseSchema, + ); + + if (options.jsonl) { + for (const item of result.items) { + wroteAny = true; + process.stdout.write(`${stringifyTerminalSafeJson(item)}\n`); + } + } else if (options.json) { + for (const item of result.items) { + wroteAny = true; + jsonItems.push(item); + } + } else { + for (const item of result.items) { + wroteAny = true; + printPackageDryRunScanResultItem(item); + } + } + + cursor = result.nextCursor; + done = result.done; + jobStatus = result.jobStatus; + jobDone = result.jobDone; + partial = result.partial; + if (options.json || done) break; + } while (cursor); + + if (options.jsonl) return; + + if (options.json) { + process.stdout.write( + `${stringifyTerminalSafeJson({ jobStatus, jobDone, partial, items: jsonItems, nextCursor: cursor, done })}\n`, + ); + return; + } + + if (!wroteAny) { + console.log("No dry-run scan results found."); + } +} + +function printPackageDryRunScanResultItem(item: PackageDryRunScanResultItem) { + const counts = `raw-fs:${item.rawFsUsageCount} fs-safe:${item.fsSafeUsageCount}`; + const findings = item.findings.length > 0 ? ` findings:${item.findings.length}` : ""; + console.log( + `${escapeTerminalControlCharacters(item.packageName)}@${escapeTerminalControlCharacters(item.version)} ${item.status} ${counts}${findings}`, + ); + for (const finding of item.findings) { + const truncated = finding.evidenceTruncated ? " (truncated)" : ""; + console.log( + ` ${escapeTerminalControlCharacters(finding.severity)} ${escapeTerminalControlCharacters(finding.code)} ${escapeTerminalControlCharacters(finding.file)}:${finding.line}: ${escapeTerminalControlCharacters(finding.message)}`, + ); + console.log(` evidence: ${escapeTerminalControlCharacters(finding.evidence)}${truncated}`); + } + for (const error of item.errors) + console.log(` error: ${escapeTerminalControlCharacters(error)}`); +} + +function isBidiControlCode(code: number) { + return ( + code === 0x061c || + code === 0x200e || + code === 0x200f || + (code >= 0x202a && code <= 0x202e) || + (code >= 0x2066 && code <= 0x2069) + ); +} + +function stringifyTerminalSafeJson(value: unknown, space?: number) { + return escapeJsonTerminalControls(JSON.stringify(value, null, space)); +} + +function escapeJsonTerminalControls(value: string) { + let escaped = ""; + for (const character of value) { + const code = character.charCodeAt(0); + if ((code >= 127 && code <= 159) || isBidiControlCode(code)) { + escaped += `\\u${code.toString(16).padStart(4, "0")}`; + } else { + escaped += character; + } + } + return escaped; +} + +async function fetchPackageDryRunScanJob( + registry: string, + token: string, + jobId: string, +): Promise { + return apiRequest( + registry, + { + method: "GET", + path: `${ApiRoutes.packages}/-/dry-run-scans/${encodeURIComponent(jobId)}`, + token, + }, + ApiV1PackageDryRunScanJobResponseSchema, + ); +} + +function isPackageDryRunScanTerminal(result: ApiV1PackageDryRunScanJobResponse) { + return result.status === "completed" || result.status === "failed"; +} + +function printPackageDryRunScanJob(result: ApiV1PackageDryRunScanJobResponse) { + console.log(`Dry-run scan ${escapeTerminalControlCharacters(result.jobId)}: ${result.status}`); + console.log( + ` total:${result.totalItems} queued:${result.queuedItems} running:${result.runningItems} completed:${result.completedItems} failed:${result.failedItems} skipped:${result.skippedItems} matched:${result.matchedItems}`, + ); + if (result.error) console.log(` error: ${escapeTerminalControlCharacters(result.error)}`); + if (result.candidateLimitReached) { + console.log(" warning: maxCandidates was reached before the full candidate set."); + } + if (!result.targetSelectionDone) { + console.log(" target selection pending."); + } +} + +function formatPackageDryRunScanJobSummary(result: ApiV1PackageDryRunScanJobResponse) { + const targetSelection = result.targetSelectionDone ? "" : ", target selection pending"; + return `Dry-run scan ${escapeTerminalControlCharacters(result.jobId)}: ${result.status} (${result.completedItems}/${result.totalItems} completed, ${result.failedItems} failed, ${result.skippedItems} skipped${targetSelection})`; +} + +function normalizePositiveInteger(value: number | undefined, fallback: number, flag: string) { + const candidate = value ?? fallback; + if (!Number.isInteger(candidate) || candidate <= 0) fail(`${flag} must be a positive integer`); + return candidate; +} + +function rejectUnusedDryRunScanSizingOptions( + options: PackageDryRunScanStartOptions, + seed?: string, +) { + const hasLimit = options.limit !== undefined; + const hasMaxCandidates = options.maxCandidates !== undefined; + if (seed) return; + if (hasMaxCandidates) fail("--max-candidates can only be used with --seed"); + if (options.latestActive === true) return; + if (hasLimit) fail("--limit can only be used with --latest-active or --seed"); +} + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + export async function cmdListPackageMigrations( opts: GlobalOpts, options: PackageMigrationListOptions = {}, @@ -699,11 +1122,33 @@ function normalizePackageNameOrFail(packageName: string) { return trimmed; } +function normalizeJobIdOrFail(jobId: string) { + const trimmed = jobId.trim(); + if (!trimmed) fail("Job id required"); + return trimmed; +} + function clampLimit(limit: number | undefined, max: number) { if (!Number.isFinite(limit)) return max; return Math.max(1, Math.min(Math.trunc(limit ?? max), max)); } +function requirePositiveBoundedInteger( + value: number | undefined, + fallback: number, + max: number, + flag: string, +) { + const candidate = value ?? fallback; + if (!Number.isInteger(candidate) || candidate < 1) { + fail(`${flag} must be a positive integer`); + } + if (candidate > max) { + fail(`${flag} must be at most ${max}`); + } + return candidate; +} + function parseCsv(value: string | undefined) { if (!value) return []; return value diff --git a/packages/clawhub/src/cli/ui.test.ts b/packages/clawhub/src/cli/ui.test.ts index 6676c64f46..24029cac76 100644 --- a/packages/clawhub/src/cli/ui.test.ts +++ b/packages/clawhub/src/cli/ui.test.ts @@ -9,7 +9,7 @@ vi.mock("node:child_process", () => ({ spawn: (...args: unknown[]) => mockSpawn(...args), })); -const { openInBrowser } = await import("./ui"); +const { formatError, openInBrowser } = await import("./ui"); type ErrorHandler = (error: NodeJS.ErrnoException) => void; @@ -75,3 +75,9 @@ describe("openInBrowser", () => { logSpy.mockRestore(); }); }); + +describe("cli ui errors", () => { + it("escapes terminal controls in formatted errors", () => { + expect(formatError(new Error("bad\n\u001b[31mboom\u202e"))).toBe("bad\\n\\x1b[31mboom\\u202e"); + }); +}); diff --git a/packages/clawhub/src/cli/ui.ts b/packages/clawhub/src/cli/ui.ts index 967e71e9d0..6c057b5a13 100644 --- a/packages/clawhub/src/cli/ui.ts +++ b/packages/clawhub/src/cli/ui.ts @@ -77,12 +77,44 @@ export function createSpinner(text: string) { return ora({ text, spinner: "dots", isEnabled: isInteractive() }).start(); } +export function escapeTerminalControlCharacters(value: string) { + let escaped = ""; + for (const character of value) { + const code = character.charCodeAt(0); + if ((code >= 0 && code <= 31) || (code >= 127 && code <= 159) || isBidiControlCode(code)) { + if (character === "\n") { + escaped += "\\n"; + } else if (character === "\r") { + escaped += "\\r"; + } else if (character === "\t") { + escaped += "\\t"; + } else if (code > 255) { + escaped += `\\u${code.toString(16).padStart(4, "0")}`; + } else { + escaped += `\\x${code.toString(16).padStart(2, "0")}`; + } + } else { + escaped += character; + } + } + return escaped; +} + export function formatError(error: unknown) { - if (error instanceof Error) return error.message; - return String(error); + return escapeTerminalControlCharacters(error instanceof Error ? error.message : String(error)); } export function fail(message: string): never { - console.error(`Error: ${message}`); + console.error(`Error: ${escapeTerminalControlCharacters(message)}`); process.exit(1); } + +function isBidiControlCode(code: number) { + return ( + code === 0x061c || + code === 0x200e || + code === 0x200f || + (code >= 0x202a && code <= 0x202e) || + (code >= 0x2066 && code <= 0x2069) + ); +} diff --git a/packages/clawhub/src/schema/packages.ts b/packages/clawhub/src/schema/packages.ts index 78bb15c7e4..906e15510e 100644 --- a/packages/clawhub/src/schema/packages.ts +++ b/packages/clawhub/src/schema/packages.ts @@ -511,6 +511,120 @@ export const ApiV1PackageArtifactBackfillResponseSchema = type({ export type ApiV1PackageArtifactBackfillResponse = (typeof ApiV1PackageArtifactBackfillResponseSchema)[inferred]; +export const PackageDryRunScanSelectorSchema = type({ + kind: '"releaseIds"', + releaseIds: "string[]", +}) + .or({ + kind: '"packageNames"', + packageNames: "string[]", + }) + .or({ + kind: '"latestActive"', + limit: "number", + }) + .or({ + kind: '"allActive"', + }) + .or({ + kind: '"seededSample"', + seed: "string", + limit: "number", + maxCandidates: "number", + }); +export type PackageDryRunScanSelector = (typeof PackageDryRunScanSelectorSchema)[inferred]; + +// Structural request shape only. HTTP and CLI entrypoints enforce selector-specific +// limits, non-empty arrays, integer bounds, and cross-field constraints. +export const PackageDryRunScanStartRequestSchema = type({ + selector: PackageDryRunScanSelectorSchema, +}); +export type PackageDryRunScanStartRequest = (typeof PackageDryRunScanStartRequestSchema)[inferred]; + +export const PackageDryRunScanJobStatusSchema = type('"queued"|"running"|"completed"|"failed"'); +export type PackageDryRunScanJobStatus = (typeof PackageDryRunScanJobStatusSchema)[inferred]; + +export const ApiV1PackageDryRunScanStartResponseSchema = type({ + jobId: "string", + status: PackageDryRunScanJobStatusSchema, + totalItems: "number", + targetSelectionDone: "boolean", + candidateLimitReached: "boolean?", +}); +export type ApiV1PackageDryRunScanStartResponse = + (typeof ApiV1PackageDryRunScanStartResponseSchema)[inferred]; + +export const ApiV1PackageDryRunScanJobResponseSchema = type({ + jobId: "string", + scanner: "string", + selector: PackageDryRunScanSelectorSchema, + status: PackageDryRunScanJobStatusSchema, + totalItems: "number", + queuedItems: "number", + runningItems: "number", + completedItems: "number", + failedItems: "number", + skippedItems: "number", + matchedItems: "number", + targetSelectionDone: "boolean", + candidateLimitReached: "boolean?", + error: "string?", + expiresAt: "number?", + createdAt: "number", + updatedAt: "number", + startedAt: "number?", + completedAt: "number?", +}); +export type ApiV1PackageDryRunScanJobResponse = + (typeof ApiV1PackageDryRunScanJobResponseSchema)[inferred]; + +export const PackageDryRunScanItemStatusSchema = type( + '"queued"|"running"|"completed"|"failed"|"skipped"', +); +export type PackageDryRunScanItemStatus = (typeof PackageDryRunScanItemStatusSchema)[inferred]; + +export const PackageDryRunScanFindingSchema = type({ + code: "string", + severity: "string", + file: "string", + line: "number", + message: "string", + evidence: "string", + evidenceTruncated: "boolean", +}); +export type PackageDryRunScanFinding = (typeof PackageDryRunScanFindingSchema)[inferred]; + +export const PackageDryRunScanResultItemSchema = type({ + itemId: "string", + jobId: "string", + releaseId: "string", + packageId: "string", + packageName: "string", + packageDisplayName: "string", + version: "string", + status: PackageDryRunScanItemStatusSchema, + rawFsUsageCount: "number", + fsSafeUsageCount: "number", + findings: PackageDryRunScanFindingSchema.array(), + errors: "string[]", + createdAt: "number", + updatedAt: "number", + startedAt: "number?", + completedAt: "number?", +}); +export type PackageDryRunScanResultItem = (typeof PackageDryRunScanResultItemSchema)[inferred]; + +export const ApiV1PackageDryRunScanResultsResponseSchema = type({ + jobStatus: PackageDryRunScanJobStatusSchema, + jobDone: "boolean", + partial: "boolean", + items: PackageDryRunScanResultItemSchema.array(), + nextCursor: "string|null", + done: "boolean", +}); +export type ApiV1PackageDryRunScanResultsResponse = + (typeof ApiV1PackageDryRunScanResultsResponseSchema)[inferred]; + export const PackageReadinessCheckSchema = type({ id: "string", label: "string", diff --git a/packages/clawhub/src/schema/textFiles.test.ts b/packages/clawhub/src/schema/textFiles.test.ts index 894b5407f0..ef98c5d31c 100644 --- a/packages/clawhub/src/schema/textFiles.test.ts +++ b/packages/clawhub/src/schema/textFiles.test.ts @@ -11,6 +11,8 @@ describe("packages/clawhub schema textFiles", () => { expect(TEXT_FILE_EXTENSION_SET.has("ps1")).toBe(true); expect(TEXT_FILE_EXTENSION_SET.has("psm1")).toBe(true); expect(TEXT_FILE_EXTENSION_SET.has("psd1")).toBe(true); + expect(TEXT_FILE_EXTENSION_SET.has("mts")).toBe(true); + expect(TEXT_FILE_EXTENSION_SET.has("cts")).toBe(true); expect(TEXT_FILE_EXTENSION_SET.has("exe")).toBe(false); }); diff --git a/packages/clawhub/src/schema/textFiles.ts b/packages/clawhub/src/schema/textFiles.ts index e3cbffaa3e..374f85921b 100644 --- a/packages/clawhub/src/schema/textFiles.ts +++ b/packages/clawhub/src/schema/textFiles.ts @@ -11,6 +11,8 @@ const RAW_TEXT_FILE_EXTENSIONS = [ "cjs", "mjs", "ts", + "mts", + "cts", "tsx", "jsx", "py", diff --git a/packages/clawhub/test/cliCommandTestKit.ts b/packages/clawhub/test/cliCommandTestKit.ts index 5983dbb775..df7efd55e2 100644 --- a/packages/clawhub/test/cliCommandTestKit.ts +++ b/packages/clawhub/test/cliCommandTestKit.ts @@ -92,10 +92,45 @@ export function createUiModuleMocks(options?: { interactive?: boolean }) { promptConfirm, moduleFactory: () => ({ createSpinner: vi.fn(() => spinner), + escapeTerminalControlCharacters: (value: string) => escapeTerminalControlCharacters(value), fail: (message: string) => fail(message), - formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)), + formatError: (error: unknown) => + escapeTerminalControlCharacters(error instanceof Error ? error.message : String(error)), isInteractive: () => interactive, promptConfirm, }), }; } + +function escapeTerminalControlCharacters(value: string) { + let escaped = ""; + for (const character of value) { + const code = character.charCodeAt(0); + if ((code >= 0 && code <= 31) || (code >= 127 && code <= 159) || isBidiControlCode(code)) { + if (character === "\n") { + escaped += "\\n"; + } else if (character === "\r") { + escaped += "\\r"; + } else if (character === "\t") { + escaped += "\\t"; + } else if (code > 255) { + escaped += `\\u${code.toString(16).padStart(4, "0")}`; + } else { + escaped += `\\x${code.toString(16).padStart(2, "0")}`; + } + } else { + escaped += character; + } + } + return escaped; +} + +function isBidiControlCode(code: number) { + return ( + code === 0x061c || + code === 0x200e || + code === 0x200f || + (code >= 0x202a && code <= 0x202e) || + (code >= 0x2066 && code <= 0x2069) + ); +} diff --git a/packages/schema/dist/packages.d.ts b/packages/schema/dist/packages.d.ts index 11a060c875..7eee79f49b 100644 --- a/packages/schema/dist/packages.d.ts +++ b/packages/schema/dist/packages.d.ts @@ -668,6 +668,165 @@ export declare const ApiV1PackageArtifactBackfillResponseSchema: import("arktype dryRun: boolean; }, {}>; export type ApiV1PackageArtifactBackfillResponse = (typeof ApiV1PackageArtifactBackfillResponseSchema)[inferred]; +export declare const PackageDryRunScanSelectorSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + kind: "releaseIds"; + releaseIds: string[]; +} | { + kind: "packageNames"; + packageNames: string[]; +} | { + kind: "latestActive"; + limit: number; +} | { + kind: "allActive"; +} | { + kind: "seededSample"; + seed: string; + limit: number; + maxCandidates: number; +}, {}>; +export type PackageDryRunScanSelector = (typeof PackageDryRunScanSelectorSchema)[inferred]; +export declare const PackageDryRunScanStartRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + selector: { + kind: "releaseIds"; + releaseIds: string[]; + } | { + kind: "packageNames"; + packageNames: string[]; + } | { + kind: "latestActive"; + limit: number; + } | { + kind: "allActive"; + } | { + kind: "seededSample"; + seed: string; + limit: number; + maxCandidates: number; + }; +}, {}>; +export type PackageDryRunScanStartRequest = (typeof PackageDryRunScanStartRequestSchema)[inferred]; +export declare const PackageDryRunScanJobStatusSchema: import("arktype/internal/variants/string.ts").StringType<"completed" | "failed" | "queued" | "running", {}>; +export type PackageDryRunScanJobStatus = (typeof PackageDryRunScanJobStatusSchema)[inferred]; +export declare const ApiV1PackageDryRunScanStartResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + jobId: string; + status: "completed" | "failed" | "queued" | "running"; + totalItems: number; + targetSelectionDone: boolean; + candidateLimitReached?: boolean | undefined; +}, {}>; +export type ApiV1PackageDryRunScanStartResponse = (typeof ApiV1PackageDryRunScanStartResponseSchema)[inferred]; +export declare const ApiV1PackageDryRunScanJobResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + jobId: string; + scanner: string; + selector: { + kind: "releaseIds"; + releaseIds: string[]; + } | { + kind: "packageNames"; + packageNames: string[]; + } | { + kind: "latestActive"; + limit: number; + } | { + kind: "allActive"; + } | { + kind: "seededSample"; + seed: string; + limit: number; + maxCandidates: number; + }; + status: "completed" | "failed" | "queued" | "running"; + totalItems: number; + queuedItems: number; + runningItems: number; + completedItems: number; + failedItems: number; + skippedItems: number; + matchedItems: number; + targetSelectionDone: boolean; + createdAt: number; + updatedAt: number; + candidateLimitReached?: boolean | undefined; + error?: string | undefined; + expiresAt?: number | undefined; + startedAt?: number | undefined; + completedAt?: number | undefined; +}, {}>; +export type ApiV1PackageDryRunScanJobResponse = (typeof ApiV1PackageDryRunScanJobResponseSchema)[inferred]; +export declare const PackageDryRunScanItemStatusSchema: import("arktype/internal/variants/string.ts").StringType<"completed" | "failed" | "queued" | "running" | "skipped", {}>; +export type PackageDryRunScanItemStatus = (typeof PackageDryRunScanItemStatusSchema)[inferred]; +export declare const PackageDryRunScanFindingSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + code: string; + severity: string; + file: string; + line: number; + message: string; + evidence: string; + evidenceTruncated: boolean; +}, {}>; +export type PackageDryRunScanFinding = (typeof PackageDryRunScanFindingSchema)[inferred]; +export declare const PackageDryRunScanResultItemSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + itemId: string; + jobId: string; + releaseId: string; + packageId: string; + packageName: string; + packageDisplayName: string; + version: string; + status: "completed" | "failed" | "queued" | "running" | "skipped"; + rawFsUsageCount: number; + fsSafeUsageCount: number; + findings: { + code: string; + severity: string; + file: string; + line: number; + message: string; + evidence: string; + evidenceTruncated: boolean; + }[]; + errors: string[]; + createdAt: number; + updatedAt: number; + startedAt?: number | undefined; + completedAt?: number | undefined; +}, {}>; +export type PackageDryRunScanResultItem = (typeof PackageDryRunScanResultItemSchema)[inferred]; +export declare const ApiV1PackageDryRunScanResultsResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + jobStatus: "completed" | "failed" | "queued" | "running"; + jobDone: boolean; + partial: boolean; + items: { + itemId: string; + jobId: string; + releaseId: string; + packageId: string; + packageName: string; + packageDisplayName: string; + version: string; + status: "completed" | "failed" | "queued" | "running" | "skipped"; + rawFsUsageCount: number; + fsSafeUsageCount: number; + findings: { + code: string; + severity: string; + file: string; + line: number; + message: string; + evidence: string; + evidenceTruncated: boolean; + }[]; + errors: string[]; + createdAt: number; + updatedAt: number; + startedAt?: number | undefined; + completedAt?: number | undefined; + }[]; + nextCursor: string | null; + done: boolean; +}, {}>; +export type ApiV1PackageDryRunScanResultsResponse = (typeof ApiV1PackageDryRunScanResultsResponseSchema)[inferred]; export declare const PackageReadinessCheckSchema: import("arktype/internal/variants/object.ts").ObjectType<{ id: string; label: string; diff --git a/packages/schema/dist/packages.js b/packages/schema/dist/packages.js index 45162f4526..970e7273b2 100644 --- a/packages/schema/dist/packages.js +++ b/packages/schema/dist/packages.js @@ -426,6 +426,97 @@ export const ApiV1PackageArtifactBackfillResponseSchema = type({ done: "boolean", dryRun: "boolean", }); +export const PackageDryRunScanSelectorSchema = type({ + kind: '"releaseIds"', + releaseIds: "string[]", +}) + .or({ + kind: '"packageNames"', + packageNames: "string[]", +}) + .or({ + kind: '"latestActive"', + limit: "number", +}) + .or({ + kind: '"allActive"', +}) + .or({ + kind: '"seededSample"', + seed: "string", + limit: "number", + maxCandidates: "number", +}); +// Structural request shape only. HTTP and CLI entrypoints enforce selector-specific +// limits, non-empty arrays, integer bounds, and cross-field constraints. +export const PackageDryRunScanStartRequestSchema = type({ + selector: PackageDryRunScanSelectorSchema, +}); +export const PackageDryRunScanJobStatusSchema = type('"queued"|"running"|"completed"|"failed"'); +export const ApiV1PackageDryRunScanStartResponseSchema = type({ + jobId: "string", + status: PackageDryRunScanJobStatusSchema, + totalItems: "number", + targetSelectionDone: "boolean", + candidateLimitReached: "boolean?", +}); +export const ApiV1PackageDryRunScanJobResponseSchema = type({ + jobId: "string", + scanner: "string", + selector: PackageDryRunScanSelectorSchema, + status: PackageDryRunScanJobStatusSchema, + totalItems: "number", + queuedItems: "number", + runningItems: "number", + completedItems: "number", + failedItems: "number", + skippedItems: "number", + matchedItems: "number", + targetSelectionDone: "boolean", + candidateLimitReached: "boolean?", + error: "string?", + expiresAt: "number?", + createdAt: "number", + updatedAt: "number", + startedAt: "number?", + completedAt: "number?", +}); +export const PackageDryRunScanItemStatusSchema = type('"queued"|"running"|"completed"|"failed"|"skipped"'); +export const PackageDryRunScanFindingSchema = type({ + code: "string", + severity: "string", + file: "string", + line: "number", + message: "string", + evidence: "string", + evidenceTruncated: "boolean", +}); +export const PackageDryRunScanResultItemSchema = type({ + itemId: "string", + jobId: "string", + releaseId: "string", + packageId: "string", + packageName: "string", + packageDisplayName: "string", + version: "string", + status: PackageDryRunScanItemStatusSchema, + rawFsUsageCount: "number", + fsSafeUsageCount: "number", + findings: PackageDryRunScanFindingSchema.array(), + errors: "string[]", + createdAt: "number", + updatedAt: "number", + startedAt: "number?", + completedAt: "number?", +}); +export const ApiV1PackageDryRunScanResultsResponseSchema = type({ + jobStatus: PackageDryRunScanJobStatusSchema, + jobDone: "boolean", + partial: "boolean", + items: PackageDryRunScanResultItemSchema.array(), + nextCursor: "string|null", + done: "boolean", +}); export const PackageReadinessCheckSchema = type({ id: "string", label: "string", diff --git a/packages/schema/dist/packages.js.map b/packages/schema/dist/packages.js.map index 98c68db967..768da5961c 100644 --- a/packages/schema/dist/packages.js.map +++ b/packages/schema/dist/packages.js.map @@ -1 +1 @@ -{"version":3,"file":"packages.js","sourceRoot":"","sources":["../src/packages.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"packages.js","sourceRoot":"","sources":["../src/packages.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/schema/dist/textFiles.d.ts b/packages/schema/dist/textFiles.d.ts index 3a44f4f82b..d684dae288 100644 --- a/packages/schema/dist/textFiles.d.ts +++ b/packages/schema/dist/textFiles.d.ts @@ -1,4 +1,4 @@ -export declare const TEXT_FILE_EXTENSIONS: readonly ["md", "mdx", "txt", "json", "json5", "yaml", "yml", "toml", "js", "cjs", "mjs", "ts", "tsx", "jsx", "py", "sh", "ps1", "psm1", "psd1", "r", "rb", "go", "rs", "swift", "kt", "java", "cs", "cpp", "c", "h", "hpp", "sql", "csv", "ini", "cfg", "env", "xml", "html", "css", "scss", "sass", "svg"]; +export declare const TEXT_FILE_EXTENSIONS: readonly ["md", "mdx", "txt", "json", "json5", "yaml", "yml", "toml", "js", "cjs", "mjs", "ts", "mts", "cts", "tsx", "jsx", "py", "sh", "ps1", "psm1", "psd1", "r", "rb", "go", "rs", "swift", "kt", "java", "cs", "cpp", "c", "h", "hpp", "sql", "csv", "ini", "cfg", "env", "xml", "html", "css", "scss", "sass", "svg"]; export declare const TEXT_FILE_EXTENSION_SET: Set; export declare const TEXT_CONTENT_TYPES: readonly ["application/json", "application/xml", "application/yaml", "application/x-yaml", "application/toml", "application/javascript", "application/typescript", "application/markdown", "image/svg+xml"]; export declare const TEXT_CONTENT_TYPE_SET: Set; diff --git a/packages/schema/dist/textFiles.js b/packages/schema/dist/textFiles.js index 0fc820b397..0ab555a83d 100644 --- a/packages/schema/dist/textFiles.js +++ b/packages/schema/dist/textFiles.js @@ -11,6 +11,8 @@ const RAW_TEXT_FILE_EXTENSIONS = [ "cjs", "mjs", "ts", + "mts", + "cts", "tsx", "jsx", "py", diff --git a/packages/schema/dist/textFiles.js.map b/packages/schema/dist/textFiles.js.map index 0c998bc8b2..c06c5e3072 100644 --- a/packages/schema/dist/textFiles.js.map +++ b/packages/schema/dist/textFiles.js.map @@ -1 +1 @@ -{"version":3,"file":"textFiles.js","sourceRoot":"","sources":["../src/textFiles.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"textFiles.js","sourceRoot":"","sources":["../src/textFiles.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/schema/src/packages.ts b/packages/schema/src/packages.ts index 8e7111a87a..cef1557c71 100644 --- a/packages/schema/src/packages.ts +++ b/packages/schema/src/packages.ts @@ -540,6 +540,120 @@ export const ApiV1PackageArtifactBackfillResponseSchema = type({ export type ApiV1PackageArtifactBackfillResponse = (typeof ApiV1PackageArtifactBackfillResponseSchema)[inferred]; +export const PackageDryRunScanSelectorSchema = type({ + kind: '"releaseIds"', + releaseIds: "string[]", +}) + .or({ + kind: '"packageNames"', + packageNames: "string[]", + }) + .or({ + kind: '"latestActive"', + limit: "number", + }) + .or({ + kind: '"allActive"', + }) + .or({ + kind: '"seededSample"', + seed: "string", + limit: "number", + maxCandidates: "number", + }); +export type PackageDryRunScanSelector = (typeof PackageDryRunScanSelectorSchema)[inferred]; + +// Structural request shape only. HTTP and CLI entrypoints enforce selector-specific +// limits, non-empty arrays, integer bounds, and cross-field constraints. +export const PackageDryRunScanStartRequestSchema = type({ + selector: PackageDryRunScanSelectorSchema, +}); +export type PackageDryRunScanStartRequest = (typeof PackageDryRunScanStartRequestSchema)[inferred]; + +export const PackageDryRunScanJobStatusSchema = type('"queued"|"running"|"completed"|"failed"'); +export type PackageDryRunScanJobStatus = (typeof PackageDryRunScanJobStatusSchema)[inferred]; + +export const ApiV1PackageDryRunScanStartResponseSchema = type({ + jobId: "string", + status: PackageDryRunScanJobStatusSchema, + totalItems: "number", + targetSelectionDone: "boolean", + candidateLimitReached: "boolean?", +}); +export type ApiV1PackageDryRunScanStartResponse = + (typeof ApiV1PackageDryRunScanStartResponseSchema)[inferred]; + +export const ApiV1PackageDryRunScanJobResponseSchema = type({ + jobId: "string", + scanner: "string", + selector: PackageDryRunScanSelectorSchema, + status: PackageDryRunScanJobStatusSchema, + totalItems: "number", + queuedItems: "number", + runningItems: "number", + completedItems: "number", + failedItems: "number", + skippedItems: "number", + matchedItems: "number", + targetSelectionDone: "boolean", + candidateLimitReached: "boolean?", + error: "string?", + expiresAt: "number?", + createdAt: "number", + updatedAt: "number", + startedAt: "number?", + completedAt: "number?", +}); +export type ApiV1PackageDryRunScanJobResponse = + (typeof ApiV1PackageDryRunScanJobResponseSchema)[inferred]; + +export const PackageDryRunScanItemStatusSchema = type( + '"queued"|"running"|"completed"|"failed"|"skipped"', +); +export type PackageDryRunScanItemStatus = (typeof PackageDryRunScanItemStatusSchema)[inferred]; + +export const PackageDryRunScanFindingSchema = type({ + code: "string", + severity: "string", + file: "string", + line: "number", + message: "string", + evidence: "string", + evidenceTruncated: "boolean", +}); +export type PackageDryRunScanFinding = (typeof PackageDryRunScanFindingSchema)[inferred]; + +export const PackageDryRunScanResultItemSchema = type({ + itemId: "string", + jobId: "string", + releaseId: "string", + packageId: "string", + packageName: "string", + packageDisplayName: "string", + version: "string", + status: PackageDryRunScanItemStatusSchema, + rawFsUsageCount: "number", + fsSafeUsageCount: "number", + findings: PackageDryRunScanFindingSchema.array(), + errors: "string[]", + createdAt: "number", + updatedAt: "number", + startedAt: "number?", + completedAt: "number?", +}); +export type PackageDryRunScanResultItem = (typeof PackageDryRunScanResultItemSchema)[inferred]; + +export const ApiV1PackageDryRunScanResultsResponseSchema = type({ + jobStatus: PackageDryRunScanJobStatusSchema, + jobDone: "boolean", + partial: "boolean", + items: PackageDryRunScanResultItemSchema.array(), + nextCursor: "string|null", + done: "boolean", +}); +export type ApiV1PackageDryRunScanResultsResponse = + (typeof ApiV1PackageDryRunScanResultsResponseSchema)[inferred]; + export const PackageReadinessCheckSchema = type({ id: "string", label: "string", diff --git a/packages/schema/src/textFiles.test.ts b/packages/schema/src/textFiles.test.ts index 61fcd1f0c9..cac2bed783 100644 --- a/packages/schema/src/textFiles.test.ts +++ b/packages/schema/src/textFiles.test.ts @@ -16,6 +16,8 @@ describe("clawhub-schema textFiles", () => { expect(TEXT_FILE_EXTENSION_SET.has("ps1")).toBe(true); expect(TEXT_FILE_EXTENSION_SET.has("psm1")).toBe(true); expect(TEXT_FILE_EXTENSION_SET.has("psd1")).toBe(true); + expect(TEXT_FILE_EXTENSION_SET.has("mts")).toBe(true); + expect(TEXT_FILE_EXTENSION_SET.has("cts")).toBe(true); expect(TEXT_FILE_EXTENSION_SET.has("exe")).toBe(false); }); @@ -27,6 +29,8 @@ describe("clawhub-schema textFiles", () => { it("guesses canonical content types for text files", () => { expect(guessTextContentType("src/index.ts")).toBe("application/typescript"); + expect(guessTextContentType("src/index.mts")).toBe("application/typescript"); + expect(guessTextContentType("src/index.cts")).toBe("application/typescript"); expect(guessTextContentType("README.md")).toBe("text/markdown"); expect(guessTextContentType("analysis/model.R")).toBe("text/plain"); expect(guessTextContentType("scripts/setup.ps1")).toBe("text/plain"); diff --git a/packages/schema/src/textFiles.ts b/packages/schema/src/textFiles.ts index df0f691c2f..f5128b7c15 100644 --- a/packages/schema/src/textFiles.ts +++ b/packages/schema/src/textFiles.ts @@ -11,6 +11,8 @@ const RAW_TEXT_FILE_EXTENSIONS = [ "cjs", "mjs", "ts", + "mts", + "cts", "tsx", "jsx", "py", diff --git a/specs/README.md b/specs/README.md index 55ff5d52b9..cadf086077 100644 --- a/specs/README.md +++ b/specs/README.md @@ -28,6 +28,7 @@ into `docs/` and leave only the design record here. - `openclaw-docs-extraction.md`: CLAW-89 extraction classification. - `deploy.md`: maintainer deploy checklist for the ClawHub project. - `security-moderation.md`: detailed moderation implementation and scanner behavior notes. +- `package-dry-run-scans.md`: admin-only dry-run scanner job invariants. - `webhook.md`: Discord webhook environment and payload notes. - `plans/plugins.md`: long-term OpenClaw plugin hosting plan. - `regression-notes/`: regression guard notes. diff --git a/specs/package-dry-run-scans.md b/specs/package-dry-run-scans.md new file mode 100644 index 0000000000..bf6a86765f --- /dev/null +++ b/specs/package-dry-run-scans.md @@ -0,0 +1,50 @@ +# Package Dry-Run Scans + +Package dry-run scans let ClawHub admins test security scanner changes against +stored plugin releases before any result is promoted into normal moderation +state. + +## Invariants + +- Dry-run scans are admin-only. API tokens and Convex entrypoints must verify + the actor is an admin before creating or reading jobs. +- Dry-run scans are read-only with respect to package moderation. They must not + patch `packageReleases.staticScan`, package `scanStatus`, moderation queue + state, or rescan request state. +- Jobs and results live in `packageDryRunScanJobs` and + `packageDryRunScanResults`; they are operational evidence, not publisher or + user-visible package state. +- Results are retained for 14 days. Pruning only deletes terminal jobs + (`completed` or `failed`) and their result rows. +- Selectors must be explicit. `allActive` intentionally has no size limit, so + CLI and HTTP callers must reject unused sizing fields instead of silently + ignoring them. +- Seeded samples must be deterministic for the same release set, seed, limit, + and candidate limit. +- Workers must use leases and claim tokens so stale attempts cannot complete, + skip, or fail a result after another worker has requeued or claimed it. +- Scanner input reads are bounded by per-file and per-release byte caps. +- Filesystem evidence is heuristic static evidence. It is intended to find + raw `fs` and `fs-safe` usage patterns for migration analysis, not to be an + AST-complete security verdict. + +## Operator Surface + +`clawhub-mod plugins dry-run-scan` supports: + +- `start`: create a job for explicit releases, package names, latest active + releases, all active releases, or a deterministic seeded sample. +- `status`: read job counters and selector metadata. +- `watch`: poll status until terminal. +- `export`: export one JSON result page with `nextCursor`, or stream all result + rows as JSONL. + +`latestActive` and `seededSample` candidate selection use active package +`latestReleaseId` releases, not older active versions. `allActive` is the broad +operator sweep and can include all active plugin releases. JSON exports include +both pagination completion (`done`) and job completion metadata (`jobStatus`, +`jobDone`, `partial`) so partial exports, including failed broad-selection jobs +that did not finish target selection, cannot be mistaken for complete runs. + +These commands are for admin validation runs only. They are not a replacement +for publish `--dry-run`, which previews a single upload before storage.