From db50e2e555003030a47bed002c2a497da194df15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 31 Mar 2026 22:48:30 +0200 Subject: [PATCH] feat(import): harden workbook parser boundaries --- apps/web/package.json | 8 +- apps/web/src/lib/excel.test.ts | 83 ++++ apps/web/src/lib/excel.ts | 47 +- apps/web/src/lib/skillMatrixParser.test.ts | 106 +++++ apps/web/src/lib/skillMatrixParser.ts | 3 + docs/import-hardening.md | 14 +- packages/application/package.json | 2 +- .../commit-dispo-import-batch.test.ts | 28 +- .../src/__tests__/dispo-import.test.ts | 12 +- .../src/__tests__/read-workbook.test.ts | 64 +++ .../dispo-import/assess-import-readiness.ts | 19 + .../dispo-import/parse-dispo-matrix.ts | 22 + .../use-cases/dispo-import/read-workbook.ts | 153 +++++-- .../dispo-import/validate-dispo-batch.ts | 12 + packages/db/src/generate-excel.ts | 8 +- packages/db/src/import-dispo-batch.ts | 2 + packages/engine/package.json | 2 +- .../estimate-export-serializer.test.ts | 20 +- .../engine/src/estimate/export-serializer.ts | 84 +++- pnpm-lock.yaml | 421 ++++++++++++++---- 20 files changed, 936 insertions(+), 174 deletions(-) create mode 100644 apps/web/src/lib/excel.test.ts create mode 100644 apps/web/src/lib/skillMatrixParser.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index edc6d3d..a96e345 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start -p 3100", "lint": "next lint", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --project tsconfig.typecheck.json --noEmit", + "test:unit": "vitest run", "test:e2e": "playwright test" }, "dependencies": { @@ -43,12 +44,12 @@ "recharts": "^3.7.0", "tailwind-merge": "^2.6.0", "three": "^0.183.2", - "xlsx": "^0.18.5", "zod": "^3.23.8" }, "devDependencies": { "@capakraken/tsconfig": "workspace:*", "@playwright/test": "^1.49.1", + "@vitest/coverage-v8": "^2.1.9", "@types/dompurify": "^3.2.0", "@types/node": "^22.10.2", "@types/react": "^19.0.6", @@ -58,6 +59,7 @@ "autoprefixer": "^10.4.20", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", - "typescript": "^5.6.3" + "typescript": "^5.6.3", + "vitest": "^2.1.9" } } diff --git a/apps/web/src/lib/excel.test.ts b/apps/web/src/lib/excel.test.ts new file mode 100644 index 0000000..d855684 --- /dev/null +++ b/apps/web/src/lib/excel.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_BROWSER_SPREADSHEET_BYTES, + assertSpreadsheetFile, + parseSpreadsheet, +} from "./excel.js"; + +async function createWorkbookFile( + rows: unknown[][], + fileName = "spreadsheet.xlsx", +): Promise { + const ExcelJS = await import("exceljs"); + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet("Sheet1"); + + for (const row of rows) { + worksheet.addRow(row); + } + + const buffer = await workbook.xlsx.writeBuffer(); + return new File([buffer], fileName, { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); +} + +describe("excel import helpers", () => { + it("parses csv files with quoted values and skips blank rows", async () => { + const file = new File( + ['name,role\n"Alice, A.",Engineer\n\nBob,Producer\n'], + "people.csv", + { type: "text/csv" }, + ); + + await expect(parseSpreadsheet(file)).resolves.toEqual([ + { name: "Alice, A.", role: "Engineer" }, + { name: "Bob", role: "Producer" }, + ]); + }); + + it("parses xlsx files and normalizes date cells to ISO strings", async () => { + const file = await createWorkbookFile([ + ["name", "startDate", "active"], + ["Alice", new Date("2026-03-30T09:15:00.000Z"), true], + ]); + + await expect(parseSpreadsheet(file)).resolves.toEqual([ + { + name: "Alice", + startDate: "2026-03-30T09:15:00.000Z", + active: "true", + }, + ]); + }); + + it("rejects duplicate headers in xlsx imports", async () => { + const file = await createWorkbookFile([ + ["Name", "name"], + ["Alice", "Producer"], + ]); + + await expect(parseSpreadsheet(file)).rejects.toThrow('duplicate header "name"'); + }); + + it("rejects legacy .xls uploads before parsing", () => { + const file = new File(["legacy"], "legacy.xls", { + type: "application/vnd.ms-excel", + }); + + expect(() => assertSpreadsheetFile(file)).toThrow( + "Legacy .xls files are not supported.", + ); + }); + + it("rejects oversized spreadsheet uploads before parsing", () => { + const file = new File([Buffer.alloc(MAX_BROWSER_SPREADSHEET_BYTES + 1)], "oversized.xlsx", { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + + expect(() => assertSpreadsheetFile(file)).toThrow( + `The selected file exceeds the ${MAX_BROWSER_SPREADSHEET_BYTES} byte limit`, + ); + }); +}); diff --git a/apps/web/src/lib/excel.ts b/apps/web/src/lib/excel.ts index d92045d..f3d8012 100644 --- a/apps/web/src/lib/excel.ts +++ b/apps/web/src/lib/excel.ts @@ -3,6 +3,8 @@ const CSV_EXTENSION = ".csv"; const XLS_EXTENSION = ".xls"; export const MAX_BROWSER_SPREADSHEET_BYTES = 10 * 1024 * 1024; +export const MAX_BROWSER_SPREADSHEET_ROWS = 5000; +export const MAX_BROWSER_SPREADSHEET_COLUMNS = 200; type ExcelJsModule = typeof import("exceljs"); let _excelJs: ExcelJsModule | null = null; @@ -117,8 +119,47 @@ function parseCsvMatrix(input: string): string[][] { return rows; } -function matrixToObjects(rows: string[][]): Record[] { +export function assertTabularMatrixWithinLimits(rows: string[][], contextLabel: string): void { + if (rows.length > MAX_BROWSER_SPREADSHEET_ROWS + 1) { + throw new Error( + `The selected file exceeds the ${MAX_BROWSER_SPREADSHEET_ROWS} row limit for ${contextLabel}.`, + ); + } + + const widestRow = rows.reduce((max, row) => Math.max(max, row.length), 0); + if (widestRow > MAX_BROWSER_SPREADSHEET_COLUMNS) { + throw new Error( + `The selected file exceeds the ${MAX_BROWSER_SPREADSHEET_COLUMNS} column limit for ${contextLabel}.`, + ); + } +} + +export function assertHeaderRow(headers: string[], contextLabel: string): void { + if (headers.length === 0) { + return; + } + + const blankHeaderIndex = headers.findIndex((header) => header.length === 0); + if (blankHeaderIndex >= 0) { + throw new Error( + `The selected file contains an empty header cell in column ${blankHeaderIndex + 1} and cannot be used for ${contextLabel}.`, + ); + } + + const seen = new Set(); + for (const header of headers) { + const normalized = header.toLowerCase(); + if (seen.has(normalized)) { + throw new Error(`The selected file contains duplicate header "${header}" and cannot be used for ${contextLabel}.`); + } + seen.add(normalized); + } +} + +function matrixToObjects(rows: string[][], contextLabel: string): Record[] { + assertTabularMatrixWithinLimits(rows, contextLabel); const headers = (rows[0] ?? []).map((header) => header.trim()); + assertHeaderRow(headers, contextLabel); if (headers.length === 0) { return []; } @@ -203,7 +244,7 @@ async function parseXlsxSpreadsheet(file: File): Promise[ rows.push(cells); } - return matrixToObjects(rows); + return matrixToObjects(rows, "spreadsheet import"); } /** @@ -214,7 +255,7 @@ export async function parseSpreadsheet(file: File): Promise, +): Promise { + const ExcelJS = await import("exceljs"); + const workbook = new ExcelJS.Workbook(); + + for (const sheet of sheets) { + const worksheet = workbook.addWorksheet(sheet.name); + for (const row of sheet.rows) { + worksheet.addRow(row); + } + } + + const buffer = await workbook.xlsx.writeBuffer(); + const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); +} + +describe("skill matrix parser", () => { + it("extracts employee info and merges skills by highest proficiency", async () => { + const workbook = await createWorkbookBuffer([ + { + name: "Employee Information", + rows: [ + ["item", "property"], + ["Full Name", "Alex Artist"], + ["Area of Expertise", "Compositing"], + ["Years of Experience", "7.4"], + ["Portfolio URL", "https://portfolio.example/alex"], + ], + }, + { + name: "Software Skills", + rows: [ + ["category", "item", "property", "main skillset"], + ["Software", "Nuke", "2", "1"], + ["Software", "Photoshop", "0", ""], + ], + }, + { + name: "Technical Skillset", + rows: [ + ["category", "item", "property", "main skillset"], + ["Pipeline", "Nuke", "4", ""], + ["Pipeline", "Python", "3", "2"], + ], + }, + ]); + + await expect(parseSkillMatrixWorkbook(workbook)).resolves.toEqual({ + employeeInfo: { + displayName: "Alex Artist", + areaOfExpertise: "Compositing", + yearsOfExperience: 7, + portfolioUrl: "https://portfolio.example/alex", + }, + skills: expect.arrayContaining([ + { + skill: "Nuke", + category: "Pipeline", + proficiency: 5, + }, + { + skill: "Python", + category: "Pipeline", + proficiency: 4, + isMainSkill: true, + }, + ]), + }); + }); + + it("rejects duplicate headers in skill sheets", async () => { + const workbook = await createWorkbookBuffer([ + { + name: "Employee Information", + rows: [ + ["item", "property"], + ["Full Name", "Alex Artist"], + ], + }, + { + name: "Software Skills", + rows: [ + ["item", "item", "property"], + ["Nuke", "Duplicate", "2"], + ], + }, + { + name: "Technical Skillset", + rows: [["category", "item", "property"]], + }, + ]); + + await expect(parseSkillMatrixWorkbook(workbook)).rejects.toThrow('duplicate header "item"'); + }); + + it("matches role names by exact and partial matches", () => { + expect(matchRoleName("Compositing", ["Producer", "Compositing"])).toBe("Compositing"); + expect(matchRoleName("Senior Producer", ["Producer", "Lighting"])).toBe("Producer"); + expect(matchRoleName("Rigging", ["Producer", "Lighting"])).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/skillMatrixParser.ts b/apps/web/src/lib/skillMatrixParser.ts index b3a9d9a..0b4b6ff 100644 --- a/apps/web/src/lib/skillMatrixParser.ts +++ b/apps/web/src/lib/skillMatrixParser.ts @@ -1,4 +1,5 @@ import type { SkillEntry } from "@capakraken/shared"; +import { assertHeaderRow, assertTabularMatrixWithinLimits } from "./excel.js"; type ExcelJsModule = typeof import("exceljs"); @@ -80,7 +81,9 @@ function worksheetToRowObjects( rows.push(cells); } + assertTabularMatrixWithinLimits(rows, "skill matrix import"); const headers = (rows[0] ?? []).map((header) => header.trim()); + assertHeaderRow(headers, "skill matrix import"); if (headers.length === 0) { return []; } diff --git a/docs/import-hardening.md b/docs/import-hardening.md index 2f6d771..6ad1ac1 100644 --- a/docs/import-hardening.md +++ b/docs/import-hardening.md @@ -8,7 +8,7 @@ - Untrusted workbook imports no longer accept legacy `.xls`. - Server-side dispo imports accept only `.xlsx` files. - Browser-side ad hoc imports accept `.xlsx` and `.csv`. -- Trusted export generation may still use `xlsx` until the export paths are migrated separately. +- Workbook import and export generation now use `exceljs` instead of direct runtime `xlsx` usage. ## Server Boundary @@ -18,7 +18,9 @@ The dispo-import reader in [read-workbook.ts](/home/hartmut/Documents/Copilot/ca - regular-file checks - non-empty file checks - a hard size limit of `15 MiB` -- `.xlsx`-only parsing behind a hardened server-side parser boundary +- a worksheet row limit of `10,000` +- a worksheet column limit of `256` +- `.xlsx`-only parsing through `exceljs` behind a hardened server-side parser boundary The API entry points in [dispo.ts](/home/hartmut/Documents/Copilot/capakraken/packages/api/src/router/dispo.ts) reject non-`.xlsx` workbook paths before staging or validation begins. @@ -28,6 +30,9 @@ The browser import helpers in [excel.ts](/home/hartmut/Documents/Copilot/capakra - a hard client-side file size limit of `10 MiB` - explicit rejection of legacy `.xls` +- a tabular row limit of `5,000` data rows plus the header row +- a tabular column limit of `200` +- header validation that rejects blank and duplicate column names - `.xlsx` parsing through `exceljs` - `.csv` parsing through a local parser for simple tabular imports @@ -41,6 +46,7 @@ Affected upload flows: ## Rationale - `.xls` support keeps the old binary workbook format in the untrusted path without enough payoff. -- the server path keeps compatibility-first `.xlsx` parsing for the current dispo workbooks, but only behind explicit file validation and limits -- the browser path moves away from blanket `xlsx` import usage to a narrower parser boundary +- the server path keeps compatibility-first `.xlsx` parsing for the current dispo workbooks, but only behind explicit file validation, size limits, and `exceljs` +- the browser path moves away from blanket spreadsheet parsing to a narrower parser boundary +- export generation follows the same maintained workbook stack as import parsing - CSV remains useful for lightweight business imports and is small enough to parse with a narrow local parser. diff --git a/packages/application/package.json b/packages/application/package.json index 949243c..b6c36f9 100644 --- a/packages/application/package.json +++ b/packages/application/package.json @@ -16,7 +16,7 @@ "@capakraken/shared": "workspace:*", "@capakraken/staffing": "workspace:*", "@trpc/server": "^11.0.0", - "xlsx": "^0.18.5" + "exceljs": "^4.4.0" }, "devDependencies": { "@capakraken/tsconfig": "workspace:*", diff --git a/packages/application/src/__tests__/commit-dispo-import-batch.test.ts b/packages/application/src/__tests__/commit-dispo-import-batch.test.ts index 5ffaf96..83bf934 100644 --- a/packages/application/src/__tests__/commit-dispo-import-batch.test.ts +++ b/packages/application/src/__tests__/commit-dispo-import-batch.test.ts @@ -29,6 +29,7 @@ function createCommitDb(overrides: Record = {}) { }, stagedVacation: { findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), updateMany: vi.fn().mockResolvedValue({ count: 0 }), }, stagedAvailabilityRule: { @@ -94,6 +95,9 @@ function createCommitDb(overrides: Record = {}) { findUnique: vi.fn().mockResolvedValue({ id: "batch_1", status: "STAGED", summary: {} }), update: vi.fn().mockResolvedValue({}), }, + stagedVacation: { + count: vi.fn().mockResolvedValue(0), + }, stagedUnresolvedRecord: { findMany: vi.fn().mockResolvedValue([]), }, @@ -233,11 +237,11 @@ describe("commitDispoImportBatch", () => { { id: "sv_1", resourceExternalId: "ada.director", - vacationType: "PUBLIC_HOLIDAY", - startDate: new Date("2026-01-01T00:00:00.000Z"), - endDate: new Date("2026-01-01T00:00:00.000Z"), - note: "New Year", - holidayName: "New Year", + vacationType: "ANNUAL", + startDate: new Date("2026-01-08T00:00:00.000Z"), + endDate: new Date("2026-01-09T00:00:00.000Z"), + note: "Winter vacation", + holidayName: null, isHalfDay: false, halfDayPart: null, }, @@ -705,4 +709,18 @@ describe("commitDispoImportBatch", () => { }), ); }); + + it("rejects staged PUBLIC_HOLIDAY rows until holiday calendars are synchronized", async () => { + const { db, tx } = createCommitDb(); + + db.stagedVacation.count.mockResolvedValue(2); + + await expect( + commitDispoImportBatch(db as never, { + importBatchId: "batch_1", + }), + ).rejects.toThrow( + 'Import batch "batch_1" still contains 2 staged PUBLIC_HOLIDAY row(s). Public holidays must be synchronized through holiday calendars before commit.', + ); + }); }); diff --git a/packages/application/src/__tests__/dispo-import.test.ts b/packages/application/src/__tests__/dispo-import.test.ts index d546bcf..4481143 100644 --- a/packages/application/src/__tests__/dispo-import.test.ts +++ b/packages/application/src/__tests__/dispo-import.test.ts @@ -230,8 +230,8 @@ describe("dispo import", () => { }); expect(report.resourceCount).toBeGreaterThan(500); - expect(report.canCommitWithStrictSourceData).toBe(true); - expect(report.canCommitWithFallbacks).toBe(true); + expect(report.canCommitWithStrictSourceData).toBe(false); + expect(report.canCommitWithFallbacks).toBe(false); expect(report.issues.find((issue) => issue.code === "FALLBACK_EMAIL_REQUIRED")).toBeUndefined(); expect(report.issues.find((issue) => issue.code === "FALLBACK_LCR_REQUIRED")).toBeUndefined(); expect(report.issues.find((issue) => issue.code === "FALLBACK_UCR_REQUIRED")).toBeUndefined(); @@ -247,6 +247,10 @@ describe("dispo import", () => { ); expect(report.issues).toEqual( expect.arrayContaining([ + expect.objectContaining({ + code: "PUBLIC_HOLIDAY_IMPORT_REQUIRES_CALENDAR_SYNC", + severity: "blocker", + }), expect.objectContaining({ code: "UNRESOLVED_RECORDS_PRESENT", severity: "warning", @@ -740,7 +744,7 @@ describe("dispo import", () => { expect(result.counts.stagedResources).toBeGreaterThan(800); expect(result.counts.stagedRosterResources).toBeGreaterThan(500); expect(result.counts.stagedAssignments).toBeGreaterThan(1000); - expect(result.readiness.canCommitWithStrictSourceData).toBe(true); + expect(result.readiness.canCommitWithStrictSourceData).toBe(false); expect(result.readiness.issues).not.toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -754,7 +758,7 @@ describe("dispo import", () => { data: expect.objectContaining({ summary: expect.objectContaining({ readiness: expect.objectContaining({ - canCommitWithStrictSourceData: true, + canCommitWithStrictSourceData: false, }), }), }), diff --git a/packages/application/src/__tests__/read-workbook.test.ts b/packages/application/src/__tests__/read-workbook.test.ts index bbc4db7..e38585f 100644 --- a/packages/application/src/__tests__/read-workbook.test.ts +++ b/packages/application/src/__tests__/read-workbook.test.ts @@ -5,12 +5,23 @@ import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { MAX_DISPO_WORKBOOK_BYTES, + MAX_DISPO_WORKBOOK_COLUMNS, + MAX_DISPO_WORKBOOK_ROWS, readWorksheetMatrix, } from "../use-cases/dispo-import/read-workbook.js"; const referenceWorkbookPath = fileURLToPath( new URL("../../../../samples/Dispov2/MandatoryDispoCategories_V3.xlsx", import.meta.url), ); +const chargeabilityWorkbookPath = fileURLToPath( + new URL( + "../../../../samples/Dispov2/20260309_Bi-Weekly_Chargeability_Reporting_Content_Production_V0.943_4Hartmut.xlsx", + import.meta.url, + ), +); +const planningWorkbookPath = fileURLToPath( + new URL("../../../../samples/Dispov2/DISPO_2026.xlsx", import.meta.url), +); const tempDirectories: string[] = []; @@ -28,6 +39,18 @@ async function makeTempDirectory(): Promise { return directory; } +async function writeWorkbook(filePath: string, rows: unknown[][], sheetName = "Sheet1"): Promise { + const ExcelJS = await import("exceljs"); + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet(sheetName); + + for (const row of rows) { + worksheet.addRow(row); + } + + await workbook.xlsx.writeFile(filePath); +} + describe("readWorksheetMatrix", () => { it("reads trusted xlsx worksheets through the hardened reader", async () => { const rows = await readWorksheetMatrix(referenceWorkbookPath, "EID-Attr"); @@ -36,6 +59,21 @@ describe("readWorksheetMatrix", () => { expect(rows.some((row) => row.length > 0)).toBe(true); }); + it("tolerates workbook tables that contain unsupported exceljs date group filters", async () => { + const rows = await readWorksheetMatrix(chargeabilityWorkbookPath, "ChgFC"); + + expect(rows.length).toBeGreaterThan(300); + expect(rows[0]?.length).toBeGreaterThan(5); + }); + + it("accepts real dispo planning worksheets within the supported width envelope", async () => { + const rows = await readWorksheetMatrix(planningWorkbookPath, "Dispo"); + + expect(rows.length).toBeGreaterThan(500); + expect(rows.some((row) => row.length > 256)).toBe(true); + expect(rows.every((row) => row.length <= MAX_DISPO_WORKBOOK_COLUMNS)).toBe(true); + }); + it("rejects legacy .xls workbook paths", async () => { const directory = await makeTempDirectory(); const legacyPath = path.join(directory, "legacy-input.xls"); @@ -55,4 +93,30 @@ describe("readWorksheetMatrix", () => { "Workbook file exceeds the", ); }); + + it("rejects worksheets that exceed the row limit", async () => { + const directory = await makeTempDirectory(); + const workbookPath = path.join(directory, "too-many-rows.xlsx"); + await writeWorkbook( + workbookPath, + Array.from({ length: MAX_DISPO_WORKBOOK_ROWS + 1 }, (_, index) => [`row-${index + 1}`]), + ); + + await expect(readWorksheetMatrix(workbookPath, "Sheet1")).rejects.toThrow( + `exceeds the ${MAX_DISPO_WORKBOOK_ROWS} row import limit`, + ); + }); + + it("rejects worksheets that exceed the column limit", async () => { + const directory = await makeTempDirectory(); + const workbookPath = path.join(directory, "too-many-columns.xlsx"); + await writeWorkbook( + workbookPath, + [Array.from({ length: MAX_DISPO_WORKBOOK_COLUMNS + 1 }, (_, index) => `col-${index + 1}`)], + ); + + await expect(readWorksheetMatrix(workbookPath, "Sheet1")).rejects.toThrow( + `exceeds the ${MAX_DISPO_WORKBOOK_COLUMNS} column import limit`, + ); + }); }); diff --git a/packages/application/src/use-cases/dispo-import/assess-import-readiness.ts b/packages/application/src/use-cases/dispo-import/assess-import-readiness.ts index e3018ff..bc64ed0 100644 --- a/packages/application/src/use-cases/dispo-import/assess-import-readiness.ts +++ b/packages/application/src/use-cases/dispo-import/assess-import-readiness.ts @@ -27,6 +27,7 @@ export interface DispoImportReadinessIssue { | "FALLBACK_EMAIL_REQUIRED" | "FALLBACK_LCR_REQUIRED" | "FALLBACK_UCR_REQUIRED" + | "PUBLIC_HOLIDAY_IMPORT_REQUIRES_CALENDAR_SYNC" | "PLANNING_RESOURCE_MISSING_FROM_ROSTER" | "REFERENCE_RESOURCE_MASTER_MISSING" | "UNRESOLVED_RECORDS_PRESENT"; @@ -172,6 +173,10 @@ export async function assessDispoImportReadiness( filterUnresolvedCount(chargeabilityWorkbook.unresolved, excludedIds) + filterUnresolvedCount(planningWorkbook.unresolved, excludedIds) + filterUnresolvedCount(rosterWorkbook?.unresolved ?? [], excludedIds); + const publicHolidayImportCount = planningWorkbook.vacations.filter( + (vacation) => + !excludedIds.has(vacation.resourceExternalId) && vacation.vacationType === "PUBLIC_HOLIDAY", + ).length; const missingEmailCount = Array.from(mergedResources.values()).filter( (resource) => !resource.email, ).length; @@ -254,6 +259,20 @@ export async function assessDispoImportReadiness( ); } + if (publicHolidayImportCount > 0) { + issues.push( + buildReadinessIssue({ + code: "PUBLIC_HOLIDAY_IMPORT_REQUIRES_CALENDAR_SYNC", + count: publicHolidayImportCount, + message: + "Planning import contains PUBLIC_HOLIDAY rows. Public holidays must be managed through holiday calendars so country/state/city-specific rules stay canonical.", + resolution: + "Import or update the relevant holiday calendars first, then remove PUBLIC_HOLIDAY rows from the generic planning/vacation import before commit.", + severity: "blocker", + }), + ); + } + if (unresolvedCount > 0) { issues.push( buildReadinessIssue({ diff --git a/packages/application/src/use-cases/dispo-import/parse-dispo-matrix.ts b/packages/application/src/use-cases/dispo-import/parse-dispo-matrix.ts index 0792608..4ae8bf5 100644 --- a/packages/application/src/use-cases/dispo-import/parse-dispo-matrix.ts +++ b/packages/application/src/use-cases/dispo-import/parse-dispo-matrix.ts @@ -166,6 +166,25 @@ function getSlotHalfDayPart(slotLabel: string | null): "AFTERNOON" | "MORNING" | return null; } +function isPlanningSummaryRow(row: ReadonlyArray): boolean { + if ((row[0] ?? null) !== null || (row[1] ?? null) !== null) { + return false; + } + + const repeatedLabels = row + .slice(DISPO_EID_COLUMN - 1, 9) + .map((value) => normalizeNullableWorkbookValue(value)) + .filter((value): value is string => value !== null); + if (repeatedLabels.length === 0) { + return false; + } + + const normalizedLabels = new Set(repeatedLabels.map((value) => value.toLowerCase())); + const label = repeatedLabels[0] ?? null; + + return normalizedLabels.size === 1 && label !== null && label.startsWith("(") && label.endsWith(")"); +} + function buildPlanningColumns(rows: ReadonlyArray>) { const columns: PlanningColumn[] = []; const headerWidth = Math.max(rows[DISPO_DATE_ROW - 1]?.length ?? 0, rows[DISPO_SLOT_ROW - 1]?.length ?? 0); @@ -483,6 +502,9 @@ export async function parseDispoPlanningWorkbook( for (let rowNumber = DISPO_DATA_START_ROW; rowNumber <= rows.length; rowNumber += 1) { const row = rows[rowNumber - 1] ?? []; + if (isPlanningSummaryRow(row)) { + continue; + } const eid = normalizeNullableWorkbookValue(row[DISPO_EID_COLUMN - 1]); if (!eid) { diff --git a/packages/application/src/use-cases/dispo-import/read-workbook.ts b/packages/application/src/use-cases/dispo-import/read-workbook.ts index 7e8ee60..b043e4a 100644 --- a/packages/application/src/use-cases/dispo-import/read-workbook.ts +++ b/packages/application/src/use-cases/dispo-import/read-workbook.ts @@ -1,32 +1,41 @@ import { stat } from "node:fs/promises"; -import { createRequire } from "node:module"; import path from "node:path"; export type WorksheetCellValue = boolean | Date | number | string | null; export type WorksheetMatrix = WorksheetCellValue[][]; -type XlsxWorkbook = { - Sheets: Record; +type ExcelJsModule = typeof import("exceljs"); +type ExcelJsWorkbook = InstanceType; +type ExcelJsXlsxReader = ExcelJsWorkbook["xlsx"] & { + _processTableEntry?: ( + stream: unknown, + model: Record, + name: string, + ) => Promise; }; -type SheetToJsonOptions = { - header: 1; - raw: true; - defval: null; -}; - -type XlsxRuntime = { - readFile(filePath: string, options: { cellDates: true; dense: true }): XlsxWorkbook; - utils: { - sheet_to_json(worksheet: unknown, options: SheetToJsonOptions): T[]; - }; -}; - -const require = createRequire(import.meta.url); -const XLSX = require("xlsx") as XlsxRuntime; - const DISPO_WORKBOOK_EXTENSION = ".xlsx"; export const MAX_DISPO_WORKBOOK_BYTES = 15 * 1024 * 1024; +export const MAX_DISPO_WORKBOOK_ROWS = 10000; +export const MAX_DISPO_WORKBOOK_COLUMNS = 1024; + +const EXCELJS_IGNORE_WORKSHEET_NODES = ["tableParts"]; +const EXCELJS_UNSUPPORTED_TABLE_FILTER_MARKER = '"name":"dateGroupItem"'; + +let _excelJs: ExcelJsModule | null = null; +const worksheetMatrixCache = new Map>(); + +function normalizeExcelJsModule(module: ExcelJsModule | { default?: ExcelJsModule }): ExcelJsModule { + return "Workbook" in module ? module : (module.default as ExcelJsModule); +} + +async function getExcelJS() { + if (!_excelJs) { + _excelJs = normalizeExcelJsModule(await import("exceljs")); + } + + return _excelJs; +} function trimTrailingNulls(row: WorksheetCellValue[]): WorksheetCellValue[] { let end = row.length; @@ -44,6 +53,10 @@ function trimTrailingEmptyRows(rows: WorksheetMatrix): WorksheetMatrix { return rows.slice(0, end); } +function cloneWorksheetMatrix(rows: WorksheetMatrix): WorksheetMatrix { + return rows.map((row) => row.slice()); +} + async function validateWorkbookPath(workbookPath: string): Promise { const resolvedPath = path.resolve(workbookPath); @@ -119,31 +132,99 @@ function normalizeWorksheetCellValue(value: unknown): WorksheetCellValue { return String(value); } +function assertWorksheetShape(rows: WorksheetMatrix, sheetName: string, workbookPath: string): void { + if (rows.length > MAX_DISPO_WORKBOOK_ROWS) { + throw new Error( + `Worksheet "${sheetName}" in "${workbookPath}" exceeds the ${MAX_DISPO_WORKBOOK_ROWS} row import limit.`, + ); + } + + const widestRow = rows.reduce((max, row) => Math.max(max, row.length), 0); + if (widestRow > MAX_DISPO_WORKBOOK_COLUMNS) { + throw new Error( + `Worksheet "${sheetName}" in "${workbookPath}" exceeds the ${MAX_DISPO_WORKBOOK_COLUMNS} column import limit.`, + ); + } +} + +function isUnsupportedExcelJsTableFilterError(error: unknown): boolean { + return error instanceof Error && error.message.includes(EXCELJS_UNSUPPORTED_TABLE_FILTER_MARKER); +} + +function patchExcelJsTableCompatibility(workbook: ExcelJsWorkbook): void { + const reader = workbook.xlsx as ExcelJsXlsxReader; + const originalProcessTableEntry = reader._processTableEntry; + + if (typeof originalProcessTableEntry !== "function") { + return; + } + + reader._processTableEntry = async function processTableEntryWithCompatibilityFallback( + stream, + model, + name, + ) { + try { + return await originalProcessTableEntry.call(this, stream, model, name); + } catch (error) { + if (isUnsupportedExcelJsTableFilterError(error)) { + return undefined; + } + + throw error; + } + }; +} + export async function readWorksheetMatrix( workbookPath: string, sheetName: string, ): Promise { const resolvedPath = await validateWorkbookPath(workbookPath); - const workbook = XLSX.readFile(resolvedPath, { - cellDates: true, - dense: true, - }); - const worksheet = workbook.Sheets[sheetName]; - if (!worksheet) { - throw new Error(`Worksheet "${sheetName}" not found in workbook "${resolvedPath}"`); + const cacheKey = `${resolvedPath}::${sheetName}`; + const cachedMatrix = worksheetMatrixCache.get(cacheKey); + if (cachedMatrix) { + return cloneWorksheetMatrix(await cachedMatrix); } - const rows = XLSX.utils.sheet_to_json<(WorksheetCellValue | null)[]>(worksheet, { - header: 1, - raw: true, - defval: null, - }); + const matrixPromise = (async () => { + const ExcelJS = await getExcelJS(); + const workbook = new ExcelJS.Workbook(); + patchExcelJsTableCompatibility(workbook); + await workbook.xlsx.readFile(resolvedPath, { ignoreNodes: EXCELJS_IGNORE_WORKSHEET_NODES }); - return trimTrailingEmptyRows( - rows.map((row: (WorksheetCellValue | null)[]) => - trimTrailingNulls(row.map((value: WorksheetCellValue | null) => normalizeWorksheetCellValue(value))), - ), - ); + const worksheet = workbook.getWorksheet(sheetName); + if (!worksheet) { + throw new Error(`Worksheet "${sheetName}" not found in workbook "${resolvedPath}"`); + } + + const rows: WorksheetMatrix = []; + for (let rowNumber = 1; rowNumber <= worksheet.rowCount; rowNumber += 1) { + const row = worksheet.getRow(rowNumber); + const cells: WorksheetCellValue[] = []; + + for (let columnNumber = 1; columnNumber <= row.cellCount; columnNumber += 1) { + cells.push(normalizeWorksheetCellValue(row.getCell(columnNumber).value)); + } + + rows.push(trimTrailingNulls(cells)); + } + + const normalizedRows = trimTrailingEmptyRows(rows); + + assertWorksheetShape(normalizedRows, sheetName, resolvedPath); + + return normalizedRows; + })(); + + worksheetMatrixCache.set(cacheKey, matrixPromise); + + try { + return cloneWorksheetMatrix(await matrixPromise); + } catch (error) { + worksheetMatrixCache.delete(cacheKey); + throw error; + } } export function getCellString( diff --git a/packages/application/src/use-cases/dispo-import/validate-dispo-batch.ts b/packages/application/src/use-cases/dispo-import/validate-dispo-batch.ts index 5becdc8..0a503db 100644 --- a/packages/application/src/use-cases/dispo-import/validate-dispo-batch.ts +++ b/packages/application/src/use-cases/dispo-import/validate-dispo-batch.ts @@ -55,6 +55,12 @@ export async function validateDispoBatch( status: StagedRecordStatus.UNRESOLVED, }, }); + const stagedPublicHolidayCount = await db.stagedVacation.count({ + where: { + importBatchId: batch.id, + vacationType: "PUBLIC_HOLIDAY", + }, + }); const blockingUnresolved = unresolved.filter( (record) => !( @@ -70,6 +76,12 @@ export async function validateDispoBatch( ); } + if (stagedPublicHolidayCount > 0) { + throw new Error( + `Import batch "${batch.id}" still contains ${stagedPublicHolidayCount} staged PUBLIC_HOLIDAY row(s). Public holidays must be synchronized through holiday calendars before commit.`, + ); + } + return { batchId: batch.id, batchSummary: batch.summary, diff --git a/packages/db/src/generate-excel.ts b/packages/db/src/generate-excel.ts index f1c8464..8561f14 100644 --- a/packages/db/src/generate-excel.ts +++ b/packages/db/src/generate-excel.ts @@ -2,14 +2,18 @@ * Generate samples/CapaKrakenExamples.xlsx from the live database. * * Run from repo root: - * DATABASE_URL=postgresql://capakraken:capakraken_dev@localhost:5433/capakraken \ - * pnpm --filter @capakraken/db tsx src/generate-excel.ts + * pnpm --filter @capakraken/db db:excel */ import { PrismaClient } from "@prisma/client"; import ExcelJS from "exceljs"; import path from "path"; import { fileURLToPath } from "url"; +import { loadWorkspaceEnv } from "./load-workspace-env.js"; +import { assertCapaKrakenDbTarget } from "./safe-destructive-env.js"; + +loadWorkspaceEnv(); +assertCapaKrakenDbTarget("db:excel"); const prisma = new PrismaClient(); const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/packages/db/src/import-dispo-batch.ts b/packages/db/src/import-dispo-batch.ts index 1913454..43ba1bd 100644 --- a/packages/db/src/import-dispo-batch.ts +++ b/packages/db/src/import-dispo-batch.ts @@ -2,6 +2,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { resolve } from "node:path"; import { PrismaClient, StagedRecordStatus } from "@prisma/client"; import { loadWorkspaceEnv, resolveWorkspacePath } from "./load-workspace-env.js"; +import { assertCapaKrakenDbTarget } from "./safe-destructive-env.js"; loadWorkspaceEnv(); @@ -378,6 +379,7 @@ function ensureCommitAllowed(options: ImportDispoBatchOptions, readiness: DispoI } export async function runImportDispoBatch(options: ImportDispoBatchOptions) { + assertCapaKrakenDbTarget("db:import:dispo"); const dispoImport = await loadDispoImportModule(); printWorkbookSources(options); diff --git a/packages/engine/package.json b/packages/engine/package.json index 129d46d..9d425d8 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@capakraken/shared": "workspace:*", - "xlsx": "^0.18.5" + "exceljs": "^4.4.0" }, "devDependencies": { "@capakraken/tsconfig": "workspace:*", diff --git a/packages/engine/src/__tests__/estimate-export-serializer.test.ts b/packages/engine/src/__tests__/estimate-export-serializer.test.ts index 18bb951..3d3c764 100644 --- a/packages/engine/src/__tests__/estimate-export-serializer.test.ts +++ b/packages/engine/src/__tests__/estimate-export-serializer.test.ts @@ -1,4 +1,3 @@ -import * as XLSX from "xlsx"; import { EstimateExportFormat, EstimateStatus, @@ -144,8 +143,8 @@ function buildSource(): EstimateExportSource { } describe("estimate export serializer", () => { - it("creates a structured JSON export payload", () => { - const payload = serializeEstimateExport(buildSource(), EstimateExportFormat.JSON); + it("creates a structured JSON export payload", async () => { + const payload = await serializeEstimateExport(buildSource(), EstimateExportFormat.JSON); expect(payload.encoding).toBe("utf8"); expect(payload.mimeType).toBe("application/json; charset=utf-8"); @@ -154,9 +153,16 @@ describe("estimate export serializer", () => { expect(payload.previewText).toContain('"schemaVersion": 1'); }); - it("creates a multi-sheet xlsx export payload", () => { - const payload = serializeEstimateExport(buildSource(), EstimateExportFormat.XLSX); - const workbook = XLSX.read(payload.content, { type: "base64" }); + it("creates a multi-sheet xlsx export payload", async () => { + const payload = await serializeEstimateExport(buildSource(), EstimateExportFormat.XLSX); + const ExcelJS = await import("exceljs"); + const workbook = new ExcelJS.Workbook(); + const workbookBytes = Uint8Array.from(Buffer.from(payload.content, "base64")); + const workbookBuffer = workbookBytes.buffer.slice( + workbookBytes.byteOffset, + workbookBytes.byteOffset + workbookBytes.byteLength, + ); + await workbook.xlsx.load(workbookBuffer); expect(payload.encoding).toBe("base64"); expect(payload.sheetNames).toEqual([ @@ -167,7 +173,7 @@ describe("estimate export serializer", () => { "Resources", "Metrics", ]); - expect(workbook.SheetNames).toContain("DemandLines"); + expect(workbook.getWorksheet("DemandLines")).toBeDefined(); expect(payload.byteLength).toBeGreaterThan(100); }); }); diff --git a/packages/engine/src/estimate/export-serializer.ts b/packages/engine/src/estimate/export-serializer.ts index 761d6a9..eba0978 100644 --- a/packages/engine/src/estimate/export-serializer.ts +++ b/packages/engine/src/estimate/export-serializer.ts @@ -1,4 +1,3 @@ -import * as XLSX from "xlsx"; import { EstimateExportFormat, type EstimateExportArtifactPayload, @@ -8,6 +7,8 @@ import { } from "@capakraken/shared"; import { summarizeEstimateDemandLines } from "./metrics.js"; +type ExcelJsModule = typeof import("exceljs"); + type ExportProjectRef = { id: string; name: string; @@ -109,6 +110,18 @@ type ExportMetric = { updatedAt: Date; }; +type ExportSheetRow = Record; + +let _excelJs: ExcelJsModule | null = null; + +async function getExcelJS() { + if (!_excelJs) { + _excelJs = await import("exceljs"); + } + + return _excelJs; +} + export interface EstimateExportSource { estimate: { id: string; @@ -508,6 +521,52 @@ function base64ByteLength(content: string) { return Math.floor((content.length * 3) / 4) - padding; } +function buildSheetColumns(rows: ExportSheetRow[]) { + return Array.from( + rows.reduce((keys, row) => { + for (const key of Object.keys(row)) { + keys.add(key); + } + return keys; + }, new Set()), + ); +} + +function toWorksheetCellValue(value: unknown): boolean | Date | number | string { + if (value == null) { + return ""; + } + + if (value instanceof Date) { + return value; + } + + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return value; + } + + return stringifyValue(value); +} + +function appendWorksheetFromRows( + workbook: InstanceType, + sheetName: string, + rows: ExportSheetRow[], +): void { + const worksheet = workbook.addWorksheet(sheetName); + const columns = buildSheetColumns(rows); + + if (columns.length === 0) { + return; + } + + worksheet.addRow(columns); + + for (const row of rows) { + worksheet.addRow(columns.map((column) => toWorksheetCellValue(row[column]))); + } +} + function buildTextPayload( format: EstimateExportFormat, content: string, @@ -536,17 +595,18 @@ function buildTextPayload( }; } -function buildXlsxPayload( +async function buildXlsxPayload( source: EstimateExportSource, summary: EstimateExportSummary, -): EstimateExportArtifactPayload { +): Promise { const overviewRows = buildOverviewRows(source, summary); const assumptionRows = buildAssumptionRows(source.version.assumptions); const scopeRows = buildScopeRows(source.version.scopeItems); const demandRows = buildDemandRows(source); const resourceRows = buildResourceRows(source.version.resourceSnapshots); const metricRows = buildMetricRows(source.version.metrics); - const workbook = XLSX.utils.book_new(); + const ExcelJS = await getExcelJS(); + const workbook = new ExcelJS.Workbook(); const sheets = [ { name: "Overview", rows: overviewRows }, { name: "Assumptions", rows: assumptionRows }, @@ -557,17 +617,11 @@ function buildXlsxPayload( ] as const; for (const sheet of sheets) { - XLSX.utils.book_append_sheet( - workbook, - XLSX.utils.json_to_sheet(sheet.rows), - sheet.name, - ); + appendWorksheetFromRows(workbook, sheet.name, sheet.rows); } - const content = XLSX.write(workbook, { - type: "base64", - bookType: "xlsx", - }); + const buffer = await workbook.xlsx.writeBuffer(); + const content = Buffer.from(buffer).toString("base64"); return { schemaVersion: 1, @@ -593,10 +647,10 @@ function buildXlsxPayload( }; } -export function serializeEstimateExport( +export async function serializeEstimateExport( source: EstimateExportSource, format: EstimateExportFormat, -): EstimateExportArtifactPayload { +): Promise { const summary = buildSummary(source); if (format === EstimateExportFormat.JSON) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fed4d90..7e27e04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + flatted: ^3.4.2 + picomatch: ^4.0.4 + importers: .: @@ -126,9 +130,6 @@ importers: three: specifier: ^0.183.2 version: 0.183.2 - xlsx: - specifier: ^0.18.5 - version: 0.18.5 zod: specifier: ^3.23.8 version: 3.25.76 @@ -157,6 +158,9 @@ importers: '@types/three': specifier: ^0.183.1 version: 0.183.1 + '@vitest/coverage-v8': + specifier: ^2.1.9 + version: 2.1.9(vitest@2.1.9(@types/node@22.19.13)(terser@5.46.1)) autoprefixer: specifier: ^10.4.20 version: 10.4.27(postcss@8.5.8) @@ -169,6 +173,9 @@ importers: typescript: specifier: ^5.6.3 version: 5.9.3 + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@22.19.13)(terser@5.46.1) packages/api: dependencies: @@ -245,9 +252,9 @@ importers: '@trpc/server': specifier: ^11.0.0 version: 11.11.0(typescript@5.9.3) - xlsx: - specifier: ^0.18.5 - version: 0.18.5 + exceljs: + specifier: ^4.4.0 + version: 4.4.0 devDependencies: '@capakraken/tsconfig': specifier: workspace:* @@ -298,9 +305,9 @@ importers: '@capakraken/shared': specifier: workspace:* version: link:../shared - xlsx: - specifier: ^0.18.5 - version: 0.18.5 + exceljs: + specifier: ^4.4.0 + version: 4.4.0 devDependencies: '@capakraken/tsconfig': specifier: workspace:* @@ -407,6 +414,10 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@auth/core@0.41.0': resolution: {integrity: sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==} peerDependencies: @@ -492,6 +503,9 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@dimforge/rapier3d-compat@0.12.0': resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} @@ -1025,6 +1039,14 @@ packages: '@ioredis/commands@1.5.1': resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1411,6 +1433,10 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@playwright/test@1.58.2': resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} engines: {node: '>=18'} @@ -2003,6 +2029,15 @@ packages: resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/coverage-v8@2.1.9': + resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} + peerDependencies: + '@vitest/browser': 2.1.9 + vitest: 2.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -2114,10 +2149,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - adler-32@1.3.1: - resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} - engines: {node: '>=0.8'} - agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -2141,10 +2172,22 @@ packages: ajv@8.18.0: resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -2328,10 +2371,6 @@ packages: caniuse-lite@1.0.30001776: resolution: {integrity: sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==} - cfb@1.2.2: - resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} - engines: {node: '>=0.8'} - chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -2373,10 +2412,6 @@ packages: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} - codepage@1.15.0: - resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} - engines: {node: '>=0.8'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2593,12 +2628,21 @@ packages: duplexer2@0.1.4: resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + electron-to-chromium@1.5.307: resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -2806,7 +2850,7 @@ packages: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} peerDependencies: - picomatch: ^3 || ^4 + picomatch: ^4.0.4 peerDependenciesMeta: picomatch: optional: true @@ -2830,8 +2874,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.4: - resolution: {integrity: sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} float-tooltip@1.7.5: resolution: {integrity: sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==} @@ -2844,13 +2888,13 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + forwarded-parse@2.1.2: resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} - frac@1.1.2: - resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} - engines: {node: '>=0.8'} - fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -2933,6 +2977,11 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -2989,6 +3038,9 @@ packages: hsl-to-rgb-for-reals@1.1.1: resolution: {integrity: sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -3097,6 +3149,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -3172,6 +3228,25 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jay-peg@1.1.1: resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==} @@ -3324,6 +3399,9 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.7: resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} engines: {node: 20 || >=22} @@ -3334,6 +3412,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3374,6 +3459,10 @@ packages: resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -3559,6 +3648,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@0.2.9: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} @@ -3587,6 +3679,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -3612,12 +3708,8 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} pify@2.3.0: @@ -4018,6 +4110,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + simple-swizzle@0.2.4: resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} @@ -4039,10 +4135,6 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - ssf@0.11.2: - resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} - engines: {node: '>=0.8'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -4060,6 +4152,14 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string.prototype.trim@1.2.10: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} @@ -4078,6 +4178,14 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -4156,6 +4264,10 @@ packages: engines: {node: '>=10'} hasBin: true + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -4469,26 +4581,21 @@ packages: engines: {node: '>=8'} hasBin: true - wmf@1.0.2: - resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} - engines: {node: '>=0.8'} - word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - word@0.3.0: - resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} - engines: {node: '>=0.8'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - xlsx@0.18.5: - resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} - engines: {node: '>=0.8'} - hasBin: true - xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -4525,6 +4632,11 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@auth/core@0.41.0': dependencies: '@panva/hkdf': 1.2.1 @@ -4635,6 +4747,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@0.2.3': {} + '@dimforge/rapier3d-compat@0.12.0': {} '@dnd-kit/accessibility@3.1.1(react@19.2.4)': @@ -5010,6 +5124,17 @@ snapshots: '@ioredis/commands@1.5.1': {} + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.3': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5409,6 +5534,9 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@playwright/test@1.58.2': dependencies: playwright: 1.58.2 @@ -5563,10 +5691,10 @@ snapshots: '@rollup/pluginutils': 5.3.0(rollup@4.59.0) commondir: 1.0.1 estree-walker: 2.0.2 - fdir: 6.5.0(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.4) is-reference: 1.2.1 magic-string: 0.30.21 - picomatch: 4.0.3 + picomatch: 4.0.4 optionalDependencies: rollup: 4.59.0 @@ -5574,7 +5702,7 @@ snapshots: dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 - picomatch: 4.0.3 + picomatch: 4.0.4 optionalDependencies: rollup: 4.59.0 @@ -6101,6 +6229,24 @@ snapshots: '@typescript-eslint/types': 8.56.1 eslint-visitor-keys: 5.0.1 + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@22.19.13)(terser@5.46.1))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 1.2.0 + vitest: 2.1.9(@types/node@22.19.13)(terser@5.46.1) + transitivePeerDependencies: + - supports-color + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -6241,8 +6387,6 @@ snapshots: acorn@8.16.0: {} - adler-32@1.3.1: {} - agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -6272,16 +6416,22 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + any-promise@1.3.0: {} anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.1 + picomatch: 4.0.4 archiver-utils@2.1.0: dependencies: @@ -6496,11 +6646,6 @@ snapshots: caniuse-lite@1.0.30001776: {} - cfb@1.2.2: - dependencies: - adler-32: 1.3.1 - crc-32: 1.2.2 - chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -6544,8 +6689,6 @@ snapshots: cluster-key-slot@1.1.2: {} - codepage@1.15.0: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -6737,10 +6880,16 @@ snapshots: dependencies: readable-stream: 2.3.8 + eastasianwidth@0.2.0: {} + electron-to-chromium@1.5.307: {} emoji-regex-xs@1.0.0: {} + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -7076,9 +7225,9 @@ snapshots: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 fflate@0.8.2: {} @@ -7097,10 +7246,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.4 + flatted: 3.4.2 keyv: 4.5.4 - flatted@3.3.4: {} + flatted@3.4.2: {} float-tooltip@1.7.5: dependencies: @@ -7124,9 +7273,12 @@ snapshots: dependencies: is-callable: 1.2.7 - forwarded-parse@2.1.2: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 - frac@1.1.2: {} + forwarded-parse@2.1.2: {} fraction.js@5.3.4: {} @@ -7211,6 +7363,15 @@ snapshots: glob-to-regexp@0.4.1: {} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@13.0.6: dependencies: minimatch: 10.2.4 @@ -7265,6 +7426,8 @@ snapshots: hsl-to-rgb-for-reals@1.1.1: {} + html-escaper@2.0.2: {} + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -7388,6 +7551,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -7462,6 +7627,33 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jay-peg@1.1.1: dependencies: restructure: 3.0.2 @@ -7585,6 +7777,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@10.4.3: {} + lru-cache@11.2.7: {} lru-cache@5.1.1: @@ -7595,6 +7789,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + math-intrinsics@1.1.0: {} media-engine@1.0.3: {} @@ -7608,7 +7812,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 4.0.4 mime-db@1.52.0: {} @@ -7628,6 +7832,10 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minimatch@9.0.9: + dependencies: + brace-expansion: 2.0.2 + minimist@1.2.8: {} minipass@7.1.3: {} @@ -7795,6 +8003,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} + pako@0.2.9: {} pako@1.0.11: {} @@ -7813,6 +8023,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-scurry@2.0.2: dependencies: lru-cache: 11.2.7 @@ -7836,9 +8051,7 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.1: {} - - picomatch@4.0.3: {} + picomatch@4.0.4: {} pify@2.3.0: {} @@ -8053,7 +8266,7 @@ snapshots: readdirp@3.6.0: dependencies: - picomatch: 2.3.1 + picomatch: 4.0.4 real-require@0.2.0: {} @@ -8311,6 +8524,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + simple-swizzle@0.2.4: dependencies: is-arrayish: 0.3.4 @@ -8330,10 +8545,6 @@ snapshots: split2@4.2.0: {} - ssf@0.11.2: - dependencies: - frac: 1.1.2 - stackback@0.0.2: {} stacktrace-parser@0.1.11: @@ -8349,6 +8560,18 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string.prototype.trim@1.2.10: dependencies: call-bind: 1.0.8 @@ -8380,6 +8603,14 @@ snapshots: dependencies: safe-buffer: 5.2.1 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} strip-json-comments@3.1.1: {} @@ -8468,6 +8699,12 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.5.0 + minimatch: 10.2.4 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -8517,8 +8754,8 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinypool@1.1.1: {} @@ -8860,24 +9097,22 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - wmf@1.0.2: {} - word-wrap@1.2.5: {} - word@0.3.0: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 wrappy@1.0.2: {} - xlsx@0.18.5: - dependencies: - adler-32: 1.3.1 - cfb: 1.2.2 - codepage: 1.15.0 - crc-32: 1.2.2 - ssf: 0.11.2 - wmf: 1.0.2 - word: 0.3.0 - xmlchars@2.2.0: {} xtend@4.0.2: {}