diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index 45518dc..59b62df 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -7,7 +7,8 @@ "dev": "export NODE_ENV=development && pnpm run build && pnpm run start", "build": "node ./build.mjs", "start": "node --enable-source-maps ./dist/index.mjs", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run" }, "dependencies": { "@workspace/api-zod": "workspace:*", @@ -28,6 +29,7 @@ "esbuild": "0.27.3", "esbuild-plugin-pino": "^2.3.3", "pino-pretty": "^13.1.3", - "thread-stream": "3.1.0" + "thread-stream": "3.1.0", + "vitest": "^4.1.8" } } diff --git a/artifacts/api-server/src/lib/lineDiff.test.ts b/artifacts/api-server/src/lib/lineDiff.test.ts new file mode 100644 index 0000000..f50d89a --- /dev/null +++ b/artifacts/api-server/src/lib/lineDiff.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { lineSimilarity, lineDiff } from "./lineDiff"; + +describe("lineSimilarity", () => { + it("is 1 for identical text", () => { + const text = "line one\nline two\nline three"; + expect(lineSimilarity(text, text)).toBe(1); + }); + + it("is 1 for two empty strings", () => { + expect(lineSimilarity("", "")).toBe(1); + }); + + it("is high for a single changed line", () => { + const prev = "a\nb\nc\nd"; + const curr = "a\nB\nc\nd"; // one of four lines changed + const sim = lineSimilarity(prev, curr); + // LCS = {a,c,d} = 3, ratio = 2*3 / (4+4) = 0.75 + expect(sim).toBeCloseTo(0.75, 10); + expect(sim).toBeGreaterThan(0.5); + }); + + it("is 0 when nothing is in common", () => { + expect(lineSimilarity("a\nb\nc", "x\ny\nz")).toBe(0); + }); + + it("is symmetric", () => { + const a = "one\ntwo\nthree\nfour"; + const b = "one\ntwo\nTHREE\nfour\nfive"; + expect(lineSimilarity(a, b)).toBeCloseTo(lineSimilarity(b, a), 10); + }); + + it("handles \\r\\n and \\n line endings equivalently", () => { + expect(lineSimilarity("a\r\nb", "a\nb")).toBe(1); + }); +}); + +describe("lineDiff", () => { + it("marks every line as context for identical input", () => { + const text = "a\nb\nc"; + const diff = lineDiff(text, text); + expect(diff).not.toBeNull(); + expect(diff!.every((d) => d.type === "context")).toBe(true); + expect(diff!.map((d) => d.text)).toEqual(["a", "b", "c"]); + }); + + it("records a removed and an added line for a single-line edit", () => { + const diff = lineDiff("a\nb\nc", "a\nB\nc"); + expect(diff).not.toBeNull(); + const removed = diff!.filter((d) => d.type === "remove"); + const added = diff!.filter((d) => d.type === "add"); + expect(removed.map((d) => d.text)).toEqual(["b"]); + expect(added.map((d) => d.text)).toEqual(["B"]); + // unchanged lines remain context + expect(diff!.filter((d) => d.type === "context").map((d) => d.text)).toEqual( + ["a", "c"], + ); + }); + + it("sets correct line numbers for context, add and remove", () => { + const diff = lineDiff("a\nb", "a\nb\nc")!; + const added = diff.find((d) => d.type === "add")!; + expect(added.text).toBe("c"); + expect(added.previousLine).toBeNull(); + expect(added.currentLine).toBe(3); + + const firstContext = diff.find((d) => d.type === "context")!; + expect(firstContext.previousLine).toBe(1); + expect(firstContext.currentLine).toBe(1); + }); + + it("treats a pure addition as only added lines", () => { + const diff = lineDiff("a", "a\nb\nc")!; + expect(diff.filter((d) => d.type === "remove")).toHaveLength(0); + expect(diff.filter((d) => d.type === "add").map((d) => d.text)).toEqual([ + "b", + "c", + ]); + }); + + it("treats a pure removal as only removed lines", () => { + const diff = lineDiff("a\nb\nc", "a")!; + expect(diff.filter((d) => d.type === "add")).toHaveLength(0); + expect(diff.filter((d) => d.type === "remove").map((d) => d.text)).toEqual([ + "b", + "c", + ]); + }); + + it("returns null when either side exceeds the diff line cap", () => { + const big = Array.from({ length: 2001 }, (_, i) => `line ${i}`).join("\n"); + expect(lineDiff(big, "a")).toBeNull(); + expect(lineDiff("a", big)).toBeNull(); + }); +}); diff --git a/artifacts/api-server/src/lib/skillFingerprint.test.ts b/artifacts/api-server/src/lib/skillFingerprint.test.ts new file mode 100644 index 0000000..a751e4e --- /dev/null +++ b/artifacts/api-server/src/lib/skillFingerprint.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { + hashText, + hashBytes, + computeFingerprint, + jaccard, +} from "./skillFingerprint"; + +describe("hashText / hashBytes", () => { + it("produces a stable sha256 hex digest", () => { + const h = hashText("hello world"); + expect(h).toBe( + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + ); + expect(h).toMatch(/^[0-9a-f]{64}$/); + }); + + it("agrees between hashText and hashBytes for the same content", () => { + const text = "some skill content\nwith two lines"; + expect(hashText(text)).toBe(hashBytes(Buffer.from(text, "utf-8"))); + }); + + it("changes when the content changes", () => { + expect(hashText("a")).not.toBe(hashText("b")); + }); +}); + +describe("computeFingerprint", () => { + const files = [ + { path: "SKILL.md", hash: "aaa" }, + { path: "scripts/run.sh", hash: "bbb" }, + { path: "data.json", hash: "ccc" }, + ]; + + it("is stable across calls with the same input", () => { + expect(computeFingerprint(files)).toBe(computeFingerprint(files)); + }); + + it("is independent of the order files are supplied in", () => { + const reordered = [files[2], files[0], files[1]]; + expect(computeFingerprint(reordered)).toBe(computeFingerprint(files)); + }); + + it("changes when a file's content hash changes", () => { + const changed = [ + files[0], + { path: "scripts/run.sh", hash: "DIFFERENT" }, + files[2], + ]; + expect(computeFingerprint(changed)).not.toBe(computeFingerprint(files)); + }); + + it("changes when a file's path changes", () => { + const renamed = [ + { path: "SKILL.md", hash: "aaa" }, + { path: "scripts/start.sh", hash: "bbb" }, + files[2], + ]; + expect(computeFingerprint(renamed)).not.toBe(computeFingerprint(files)); + }); + + it("changes when a file is added or removed", () => { + const fewer = [files[0], files[1]]; + expect(computeFingerprint(fewer)).not.toBe(computeFingerprint(files)); + }); + + it("returns a 64-char hex digest", () => { + expect(computeFingerprint(files)).toMatch(/^[0-9a-f]{64}$/); + }); +}); + +describe("jaccard", () => { + it("is 1 for identical non-empty sets", () => { + expect(jaccard(new Set(["a", "b"]), new Set(["a", "b"]))).toBe(1); + }); + + it("is 0 for disjoint sets", () => { + expect(jaccard(new Set(["a"]), new Set(["b"]))).toBe(0); + }); + + it("computes intersection over union for partial overlap", () => { + // intersection {a} = 1, union {a,b,c} = 3 + expect(jaccard(new Set(["a", "b"]), new Set(["a", "c"]))).toBeCloseTo( + 1 / 3, + 10, + ); + }); + + it("is symmetric", () => { + const a = new Set(["a", "b", "c"]); + const b = new Set(["b", "c", "d"]); + expect(jaccard(a, b)).toBe(jaccard(b, a)); + }); + + it("treats two empty sets as 0 (no overlap to report)", () => { + expect(jaccard(new Set(), new Set())).toBe(0); + }); +}); diff --git a/artifacts/api-server/src/routes/compare.test.ts b/artifacts/api-server/src/routes/compare.test.ts new file mode 100644 index 0000000..55eebd8 --- /dev/null +++ b/artifacts/api-server/src/routes/compare.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import type { AddressInfo } from "node:net"; +import type { Server } from "node:http"; +import app from "../app"; +import { db, pool, scansTable, scanFilesTable } from "@workspace/db"; +import { inArray } from "drizzle-orm"; +import { hashText, computeFingerprint } from "../lib/skillFingerprint"; + +const emptyCounts = { + critical: 0, + high: 0, + medium: 0, + low: 0, + info: 0, + security: 0, + privacy: 0, + total: 0, +}; + +type FixtureFile = { path: string; content: string }; + +const createdScanIds: number[] = []; +let server: Server; +let baseUrl: string; + +beforeAll(async () => { + await new Promise((resolve) => { + server = app.listen(0, () => resolve()); + }); + const { port } = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${port}`; +}); + +afterAll(async () => { + if (createdScanIds.length > 0) { + await db + .delete(scansTable) + .where(inArray(scansTable.id, createdScanIds.splice(0))); + } + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + await pool.end(); +}); + +async function insertScan( + name: string, + files: FixtureFile[], +): Promise { + const withHash = files.map((f) => ({ ...f, hash: hashText(f.content) })); + const fingerprint = computeFingerprint( + withHash.map((f) => ({ path: f.path, hash: f.hash })), + ); + const [scan] = await db + .insert(scansTable) + .values({ + name, + source: "zip", + status: "completed", + verdict: "pass", + riskScore: 0, + fileCount: files.length, + aiUsed: false, + findingCounts: emptyCounts, + fingerprint, + relation: "new", + }) + .returning(); + createdScanIds.push(scan.id); + await db.insert(scanFilesTable).values( + withHash.map((f) => ({ + scanId: scan.id, + path: f.path, + kind: f.path.toLowerCase().endsWith(".md") ? "instruction" : "resource", + language: null, + size: Buffer.byteLength(f.content, "utf-8"), + hash: f.hash, + content: f.content, + })), + ); + return scan.id; +} + +describe("GET /api/scans/:id/compare/:otherId", () => { + it("returns correct per-file statuses and a line diff for the modified file", async () => { + const previousId = await insertScan("v1", [ + { path: "SKILL.md", content: "# Skill\nstep one\nstep two\nstep three" }, + { path: "removed.md", content: "this file goes away" }, + { path: "stable.md", content: "never changes" }, + ]); + const currentId = await insertScan("v2", [ + { + path: "SKILL.md", + content: "# Skill\nstep one\nstep TWO\nstep three", + }, + { path: "added.md", content: "brand new file" }, + { path: "stable.md", content: "never changes" }, + ]); + + const res = await fetch( + `${baseUrl}/api/scans/${currentId}/compare/${previousId}`, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + current: { id: number }; + previous: { id: number }; + files: { + path: string; + status: string; + lineDiff: { type: string; text: string }[] | null; + }[]; + }; + + expect(body.current.id).toBe(currentId); + expect(body.previous.id).toBe(previousId); + + const byPath: Record = + Object.fromEntries(body.files.map((f) => [f.path, f])); + + expect(byPath["SKILL.md"].status).toBe("modified"); + expect(byPath["stable.md"].status).toBe("unchanged"); + expect(byPath["added.md"].status).toBe("added"); + expect(byPath["removed.md"].status).toBe("removed"); + + // Files are returned sorted by path. + expect(body.files.map((f: { path: string }) => f.path)).toEqual( + ["SKILL.md", "added.md", "removed.md", "stable.md"], + ); + + // The modified text file carries a line diff with the changed line. + const diff = byPath["SKILL.md"].lineDiff as + | { type: string; text: string }[] + | null; + expect(diff).not.toBeNull(); + expect(diff!.find((d) => d.type === "remove")?.text).toBe("step two"); + expect(diff!.find((d) => d.type === "add")?.text).toBe("step TWO"); + expect(diff!.filter((d) => d.type === "context").map((d) => d.text)).toEqual( + ["# Skill", "step one", "step three"], + ); + + // Unchanged / added / removed files do not get a line diff. + expect(byPath["stable.md"].lineDiff).toBeNull(); + expect(byPath["added.md"].lineDiff).toBeNull(); + expect(byPath["removed.md"].lineDiff).toBeNull(); + }); + + it("returns 404 when a scan does not exist", async () => { + const res = await fetch(`${baseUrl}/api/scans/999999999/compare/999999998`); + expect(res.status).toBe(404); + }); +}); diff --git a/artifacts/api-server/src/routes/relation.test.ts b/artifacts/api-server/src/routes/relation.test.ts new file mode 100644 index 0000000..5133977 --- /dev/null +++ b/artifacts/api-server/src/routes/relation.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, afterEach, afterAll } from "vitest"; +import { db, pool, scansTable, scanFilesTable } from "@workspace/db"; +import { eq, inArray } from "drizzle-orm"; +import { hashText, computeFingerprint } from "../lib/skillFingerprint"; +import type { ParsedFile } from "../lib/ruleCatalog"; +import { + computeRelation, + computeContentSimilarity, + countFingerprint, +} from "./scans"; + +const emptyCounts = { + critical: 0, + high: 0, + medium: 0, + low: 0, + info: 0, + security: 0, + privacy: 0, + total: 0, +}; + +const createdScanIds: number[] = []; + +function file(path: string, content: string): ParsedFile { + return { + path, + kind: path.toLowerCase().endsWith(".md") ? "instruction" : "resource", + language: null, + content, + size: Buffer.byteLength(content, "utf-8"), + hash: hashText(content), + isBinary: false, + }; +} + +async function insertScan(name: string, files: ParsedFile[]): Promise { + const fingerprint = computeFingerprint( + files.map((f) => ({ path: f.path, hash: f.hash })), + ); + const [scan] = await db + .insert(scansTable) + .values({ + name, + source: "text", + status: "completed", + verdict: "pass", + riskScore: 0, + fileCount: files.length, + aiUsed: false, + findingCounts: emptyCounts, + fingerprint, + relation: "new", + }) + .returning(); + createdScanIds.push(scan.id); + await db.insert(scanFilesTable).values( + files.map((f) => ({ + scanId: scan.id, + path: f.path, + kind: f.kind, + language: f.language, + size: f.size, + hash: f.hash, + content: f.isBinary ? null : f.content, + })), + ); + return scan.id; +} + +afterEach(async () => { + if (createdScanIds.length > 0) { + await db + .delete(scansTable) + .where(inArray(scansTable.id, createdScanIds.splice(0))); + } +}); + +afterAll(async () => { + await pool.end(); +}); + +describe("computeRelation", () => { + it("flags byte-identical content (with a different name) as identical and bumps the check counter", async () => { + // Use highly unique content so the fingerprint cannot collide with any + // pre-existing scans in the shared dev database. + const body = + "# Unique relation-test skill " + + Math.random().toString(36).slice(2) + + "\nDo a very specific thing.\n"; + const original = [file("SKILL.md", body)]; + const baselineId = await insertScan("Original Name", original); + + const fingerprint = computeFingerprint( + original.map((f) => ({ path: f.path, hash: f.hash })), + ); + + // A re-upload of the exact same bytes under a different display name. + const relation = await computeRelation(fingerprint, original); + expect(relation.relation).toBe("identical"); + expect(relation.similarity).toBe(100); + expect(relation.comparedScanId).toBe(baselineId); + + // Check counter: only the baseline exists so far -> 1. + expect(await countFingerprint(fingerprint)).toBe(1); + // Storing the identical re-upload increments the counter to 2. + await insertScan("Different Name", original); + expect(await countFingerprint(fingerprint)).toBe(2); + }); + + it("flags a one-line edit to a single-file skill as modified with a sensible similarity", async () => { + const tag = Math.random().toString(36).slice(2); + const baseLines = [ + `# Modified-test skill ${tag}`, + "Step one: gather inputs.", + "Step two: process them.", + "Step three: report results.", + "Step four: clean up.", + ]; + const original = [file("SKILL.md", baseLines.join("\n"))]; + const baselineId = await insertScan("Edited Skill", original); + + // Change exactly one line. + const editedLines = [...baseLines]; + editedLines[2] = "Step two: process them carefully."; + const edited = [file("SKILL.md", editedLines.join("\n"))]; + const fingerprint = computeFingerprint( + edited.map((f) => ({ path: f.path, hash: f.hash })), + ); + + const relation = await computeRelation(fingerprint, edited); + expect(relation.relation).toBe("modified"); + expect(relation.comparedScanId).toBe(baselineId); + expect(relation.similarity).not.toBeNull(); + // One of five lines changed -> high but not perfect similarity. + expect(relation.similarity!).toBeGreaterThanOrEqual(60); + expect(relation.similarity!).toBeLessThan(100); + }); + + it("flags an unrelated single-file skill as new", async () => { + const tag = Math.random().toString(36).slice(2); + // Establish a baseline skill. + await insertScan("Baseline Skill", [ + file( + "SKILL.md", + `# Baseline ${tag}\nalpha bravo charlie\ndelta echo foxtrot\ngolf hotel india`, + ), + ]); + + // A completely different single-file skill. It shares the common path + // "SKILL.md" but has no content overlap, so it must be "new". + const unrelated = [ + file( + "SKILL.md", + `# Totally different ${tag}\nzulu yankee xray\nwhiskey victor uniform\ntango sierra romeo`, + ), + ]; + const fingerprint = computeFingerprint( + unrelated.map((f) => ({ path: f.path, hash: f.hash })), + ); + + const relation = await computeRelation(fingerprint, unrelated); + expect(relation.relation).toBe("new"); + expect(relation.similarity).toBeNull(); + expect(relation.comparedScanId).toBeNull(); + }); +}); + +describe("computeContentSimilarity", () => { + it("is 100 when every file is byte-identical", () => { + const files = [file("SKILL.md", "same content\nline two")]; + const prior = new Map( + files.map((f) => [f.path, { hash: f.hash, content: f.content }]), + ); + expect(computeContentSimilarity(files, prior)).toBe(100); + }); + + it("uses line-level similarity for a changed text file", () => { + const current = [file("SKILL.md", "a\nB\nc\nd")]; + const prior = new Map([ + ["SKILL.md", { hash: hashText("a\nb\nc\nd"), content: "a\nb\nc\nd" }], + ]); + // 3 of 4 lines shared -> 0.75 -> 75 (single shared path). + expect(computeContentSimilarity(current, prior)).toBe(75); + }); + + it("counts added or removed files as zero contribution", () => { + const current = [file("SKILL.md", "shared"), file("extra.md", "new file")]; + const prior = new Map([ + ["SKILL.md", { hash: hashText("shared"), content: "shared" }], + ]); + // SKILL.md identical (1.0), extra.md added (0). Union of paths = 2 -> 50. + expect(computeContentSimilarity(current, prior)).toBe(50); + }); +}); diff --git a/artifacts/api-server/src/routes/scans.ts b/artifacts/api-server/src/routes/scans.ts index ddff4de..29e1748 100644 --- a/artifacts/api-server/src/routes/scans.ts +++ b/artifacts/api-server/src/routes/scans.ts @@ -90,7 +90,7 @@ async function resolveComparedScan( }; } -async function countFingerprint(fingerprint: string): Promise { +export async function countFingerprint(fingerprint: string): Promise { if (!fingerprint) return 1; const [row] = await db .select({ c: count() }) @@ -156,7 +156,7 @@ type RelationInfo = { * skill (when it overlaps enough or shares a byte-identical file) -> modified; * nothing meaningfully in common -> new. */ -async function computeRelation( +export async function computeRelation( fingerprint: string, files: ParsedFile[], ): Promise { @@ -261,7 +261,7 @@ const MODIFIED_SIMILARITY_THRESHOLD = 40; * scan. Identical files (same hash) count fully; changed text files use the * line-level similarity; added/removed or changed binary files count as 0. */ -function computeContentSimilarity( +export function computeContentSimilarity( newFiles: ParsedFile[], prior: Map, ): number { diff --git a/artifacts/api-server/vitest.config.ts b/artifacts/api-server/vitest.config.ts new file mode 100644 index 0000000..54ca4ec --- /dev/null +++ b/artifacts/api-server/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + conditions: ["workspace"], + }, + test: { + include: ["src/**/*.test.ts"], + environment: "node", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 888f97f..e7a696c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,6 +218,9 @@ importers: thread-stream: specifier: 3.1.0 version: 3.1.0 + vitest: + specifier: ^4.1.8 + version: 4.1.8(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4) artifacts/mockup-sandbox: devDependencies: @@ -1568,6 +1571,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tabby_ai/hijri-converter@1.0.5': resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==} engines: {node: '>=16.0.0'} @@ -1631,6 +1637,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -1669,6 +1678,9 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1719,6 +1731,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -1757,6 +1798,10 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -1802,6 +1847,10 @@ packages: caniuse-lite@1.0.30001792: resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -2109,6 +2158,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -2138,6 +2190,9 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2153,6 +2208,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -2527,6 +2586,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@2.1.2: + resolution: {integrity: sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==} + engines: {node: '>=12.20.0'} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -2900,6 +2963,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -2925,10 +2991,16 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -2966,10 +3038,21 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3136,11 +3219,56 @@ packages: yaml: optional: true + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wouter@3.10.0: resolution: {integrity: sha512-zTfddD80zc2/J5l8JKcdvzOK6AwP0kpyHEI3DxRN2bn8U1oJPnrSVm8v+X3WwDamvLAOxTO7ZvkxkpRWlyeJ1Q==} peerDependencies: @@ -4245,6 +4373,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@tabby_ai/hijri-converter@1.0.5': {} '@tailwindcss/node@4.3.0': @@ -4313,6 +4443,11 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 25.6.2 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/connect@3.4.38': dependencies: '@types/node': 25.6.2 @@ -4349,6 +4484,8 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/express-serve-static-core@5.1.1': @@ -4415,6 +4552,47 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.8': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.8(vite@7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4))': + dependencies: + '@vitest/spy': 4.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4) + + '@vitest/pretty-format@4.1.8': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.8': + dependencies: + '@vitest/utils': 4.1.8 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.8': {} + + '@vitest/utils@4.1.8': + dependencies: + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -4447,6 +4625,8 @@ snapshots: dependencies: tslib: 2.8.1 + assertion-error@2.0.1: {} + atomic-sleep@1.0.0: {} balanced-match@4.0.4: {} @@ -4497,6 +4677,8 @@ snapshots: caniuse-lite@1.0.30001792: {} + chai@6.2.2: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -4685,6 +4867,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -4705,6 +4889,10 @@ snapshots: escape-html@1.0.3: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} etag@1.8.1: {} @@ -4726,6 +4914,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expect-type@1.3.0: {} + express@5.2.1: dependencies: accepts: 2.0.0 @@ -5071,6 +5261,8 @@ snapshots: object-inspect@1.13.4: {} + obug@2.1.2: {} + on-exit-leak-free@2.1.2: {} on-finished@2.4.1: @@ -5494,6 +5686,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} slash@5.1.0: {} @@ -5511,8 +5705,12 @@ snapshots: split2@4.2.0: {} + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.1.0: {} + string-argv@0.3.2: {} strip-ansi@6.0.1: @@ -5539,11 +5737,17 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -5673,10 +5877,52 @@ snapshots: tsx: 4.21.0 yaml: 2.8.4 + vitest@4.1.8(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(vite@7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.2 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 7.3.3(@types/node@25.6.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.6.2 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wouter@3.10.0(react@19.1.0): dependencies: mitt: 3.0.1