From 03210ff2b9742435e63861692953a942ef7586f0 Mon Sep 17 00:00:00 2001 From: Masao Kitamura Date: Thu, 1 Oct 2026 17:03:14 -0700 Subject: [PATCH] Add the pnpm workspace and packages/core The engine starts as @costexplorer/core: the cost record schema aligned to the FOCUS columns, money math that adds in micro-dollars, and the Finding contract with ranking. The app stays at the repository root and packages live beside it. Vitest runs the package tests with a 90% coverage threshold. Lint keeps React and Next.js out of the engine packages. Claude-Session: https://claude.ai/code/session_01WTtRZcdke1yYdYUAanaf7V --- .prettierignore | 1 + CONTRIBUTING.md | 5 +- README.md | 12 +- docs/engineering-practices.md | 25 +- docs/guardrails.md | 3 + eslint.config.mjs | 14 + package.json | 9 +- packages/core/node_modules/zod | 1 + packages/core/package.json | 15 + packages/core/src/cost-record.test.ts | 64 +++ packages/core/src/cost-record.ts | 56 +++ packages/core/src/finding.test.ts | 78 +++ packages/core/src/finding.ts | 48 ++ packages/core/src/index.ts | 17 + packages/core/src/money.test.ts | 41 ++ packages/core/src/money.ts | 28 ++ packages/core/tsconfig.json | 4 + pnpm-lock.yaml | 608 ++++++++++++++++++++++++ pnpm-workspace.yaml | 2 + scripts/checks/lint-boundaries.test.mjs | 9 + tsconfig.json | 2 +- tsconfig.packages.json | 18 + vitest.config.mts | 14 + 23 files changed, 1058 insertions(+), 16 deletions(-) create mode 120000 packages/core/node_modules/zod create mode 100644 packages/core/package.json create mode 100644 packages/core/src/cost-record.test.ts create mode 100644 packages/core/src/cost-record.ts create mode 100644 packages/core/src/finding.test.ts create mode 100644 packages/core/src/finding.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/money.test.ts create mode 100644 packages/core/src/money.ts create mode 100644 packages/core/tsconfig.json create mode 100644 tsconfig.packages.json create mode 100644 vitest.config.mts diff --git a/.prettierignore b/.prettierignore index bd5535a..d0151dd 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ pnpm-lock.yaml +coverage diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5fd2de1..55611d1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,8 +13,9 @@ The install sets up two git hooks. The pre-commit hook formats the files you sta 1. Branch from `main`. Nothing is pushed to `main` directly. 2. Keep a feature's files together in `src/features//`. A new URL also needs a row in the [feature map](docs/feature-map.md). -3. Run `pnpm verify` before you push. It runs what CI runs. -4. Open one pull request per issue. Say why the change exists, what is in scope and how you verified it, and paste the `pnpm verify` output. +3. Engine code goes in `packages//src/` with its test beside it as `.test.ts`. Import a sibling file with its `.ts` extension, and do not import React or Next.js there. +4. Run `pnpm verify` before you push. It runs what CI runs. +5. Open one pull request per issue. Say why the change exists, what is in scope and how you verified it, and paste the `pnpm verify` output. ## Formatting and lint diff --git a/README.md b/README.md index 50fd752..a2efe25 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,14 @@ # costexplorer -A Next.js 16 app. It currently serves only the create-next-app starting page, and the tooling around it is in place for the first real feature. +The open-source engine behind Cost Explorer. It reads a cloud cost export and reports what is being wasted, with the saving and the fix for each finding. + +The repository is a pnpm workspace. The engine is built as packages under `packages/`, and a Next.js 16 app at the root will host the docs and the rule catalog. The app still serves the create-next-app starting page. + +| Package | Holds | +| --------------- | --------------------------------------------------------------------------------------------------- | +| `packages/core` | The cost record schema, aligned to the FinOps FOCUS columns, money math and the `Finding` contract. | + +The engine packages are framework-free TypeScript. They import neither React nor Next.js, so a command-line tool and a worker can run them. ## Run it @@ -20,7 +28,7 @@ Open http://localhost:3000. The homepage is `src/features/home/home-page.tsx`. pnpm verify ``` -This runs typecheck, lint, format check, repository checks, tests, the production build and a smoke test of every URL. CI runs the same commands as separate jobs in `.github/workflows/ci.yml`. +This runs typecheck, lint, format check, repository checks, tests, the production build and a smoke test of every URL. The tests include the package tests, which must cover 90% of the package code. CI runs the same commands as separate jobs in `.github/workflows/ci.yml`. ## Documentation diff --git a/docs/engineering-practices.md b/docs/engineering-practices.md index 47d578f..7b34373 100644 --- a/docs/engineering-practices.md +++ b/docs/engineering-practices.md @@ -88,14 +88,16 @@ JSON Each item names what would change the answer. -| Id | Technique | Why not here | Adopt it when | -| --------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| 4.3 | Import smoke test | The only entry points outside Next.js are `scripts/check.mjs`, `scripts/smoke.mjs` and `scripts/preinstall.mjs`. CI executes all three for real in every run. | The repository gains a command-line script, a daemon or a scheduled job that CI does not run. | -| 4.4 | Changed-files gate | The full gate takes about 13 seconds on a laptop. Skip logic is untested code that can skip by accident, and it would save seconds. | The full CI run takes longer than about ten minutes. | -| 5.1 to 5.3, 5.5 | Migrations and the runner lock | The app has no database and no other durable state. | The first table, bucket, queue or index is added. | -| 2.1 | The `server-only` marker package | There is no server-only module to mark. Declaring the dependency with no importer would be configuration without effect. | The first module reads a credential. [Guardrails](guardrails.md#adding-a-credential) has the steps. | -| none | A unit test runner | Nothing in `src` has branching logic to test. The repository checks use the test runner built into Node. | A feature gains logic that a request to the page cannot prove. | -| none | Browser tests in CI | They need a browser download on every run, and the app has no interactive component yet. | The first interactive component ships. | +| Id | Technique | Why not here | Adopt it when | +| --------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | +| 4.3 | Import smoke test | The only entry points outside Next.js are `scripts/check.mjs`, `scripts/smoke.mjs` and `scripts/preinstall.mjs`. CI executes all three for real in every run. | The repository gains a command-line script, a daemon or a scheduled job that CI does not run. | +| 4.4 | Changed-files gate | The full gate takes about 13 seconds on a laptop. Skip logic is untested code that can skip by accident, and it would save seconds. | The full CI run takes longer than about ten minutes. | +| 5.1 to 5.3, 5.5 | Migrations and the runner lock | The app has no database and no other durable state. | The first table, bucket, queue or index is added. | +| 2.1 | The `server-only` marker package | There is no server-only module to mark. Declaring the dependency with no importer would be configuration without effect. | The first module reads a credential. [Guardrails](guardrails.md#adding-a-credential) has the steps. | +| none | Turborepo | The whole gate takes about 15 seconds with one package. A task cache is a second source of truth for what ran. | The gate takes longer than a few minutes, or packages need a build order pnpm cannot infer. | +| none | Changesets | Every package is private and unpublished. Release tooling with nothing to release is configuration without effect. | The first package is published to npm. | +| none | Moving the app to `apps/web` | The app stays at the repository root and the engine lives beside it in `packages/`. The move rewrites every check, the feature map and the agent instructions, and no engine code depends on it. | A second app is added, or the root app's configuration starts leaking into a package. | +| none | Browser tests in CI | They need a browser download on every run, and the app has no interactive component yet. | The first interactive component ships. | ## Tool failure modes @@ -123,3 +125,10 @@ FAIL built route /favicon.ico has no row in docs/feature-map.md FAIL /: expected 200 and text "Welcome back" 1 surfaces requested, 2 failures ``` + +The coverage threshold was failed by hand on 2026-10-01 by leaving one branch of the ranking comparator untested. + +```text +Branches : 88.88% ( 8/9 ) +ERROR: Coverage for branches (88.88%) does not meet global threshold (90%) +``` diff --git a/docs/guardrails.md b/docs/guardrails.md index 6775e5e..178bbfb 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -13,6 +13,9 @@ Each rule this repository holds, and the mechanism that fails when the rule is b | `src/lib` and `src/components` do not import a feature or a route | `no-restricted-imports` in `eslint.config.mjs` | `scripts/checks/lint-boundaries.test.mjs` | | No parent-relative imports in `src` | `no-restricted-imports` in `eslint.config.mjs` | `scripts/checks/lint-boundaries.test.mjs` | | No `process.env` in `src`, so no credential can reach a client bundle | `no-restricted-properties` in `eslint.config.mjs` | `scripts/checks/lint-boundaries.test.mjs` | +| The engine packages do not import React or Next.js | `no-restricted-imports` in `eslint.config.mjs` | `scripts/checks/lint-boundaries.test.mjs` | +| Package tests pass and cover 90% of `packages/*/src` | `pnpm test` in CI job `test`, thresholds in `vitest.config.mts` | by hand, see the [audit](engineering-practices.md#evidence) | +| Package code uses only syntax Node can run without a build | `erasableSyntaxOnly` in `tsconfig.packages.json`, run by `pnpm typecheck` | compiler | | Every URL has a row in the [feature map](feature-map.md), and every row is real | `pnpm check` in CI job `check` | `scripts/checks/checks.test.mjs` | | Relative links and heading anchors in markdown resolve | `pnpm check` in CI job `check` | `scripts/checks/checks.test.mjs` | | Node has one version, in `.nvmrc`. `engines`, `@types/node` and CI agree with it | `pnpm check` in CI job `check` | `scripts/checks/checks.test.mjs` | diff --git a/eslint.config.mjs b/eslint.config.mjs index 7de6558..81761e6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,12 @@ const noProcessEnv = { 'Read configuration in one module that starts with `import "server-only"`, then exempt only that file from this rule.', }; +const frameworkImport = { + regex: "^(react|react-dom|next)(/|$)", + message: + "The engine packages stay framework-free so the CLI and the worker can run them. Rendering belongs in packages/ui.", +}; + const features = readdirSync("src/features", { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name); @@ -64,6 +70,13 @@ const eslintConfig = defineConfig([ ], }, })), + { + files: ["packages/**"], + ignores: ["packages/ui/**"], + rules: { + "no-restricted-imports": ["error", { patterns: [frameworkImport] }], + }, + }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: @@ -71,6 +84,7 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + "coverage/**", ]), ]); diff --git a/package.json b/package.json index ac1cfc5..55feff5 100644 --- a/package.json +++ b/package.json @@ -2,17 +2,18 @@ "name": "costexplorer", "version": "0.1.0", "private": true, + "license": "Apache-2.0", "scripts": { "dev": "next dev", "build": "next build", "start": "next start", - "typecheck": "next typegen && tsc --noEmit", + "typecheck": "next typegen && tsc --noEmit && pnpm -r typecheck", "lint": "eslint --max-warnings 0", "lint:fix": "eslint --fix", "format": "prettier --write .", "format:check": "prettier --check .", "check": "node scripts/check.mjs", - "test": "node --test \"scripts/**/*.test.mjs\"", + "test": "node --test \"scripts/**/*.test.mjs\" && vitest run --coverage", "smoke": "node scripts/smoke.mjs", "verify": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm check && pnpm test && pnpm build && pnpm smoke", "preinstall": "node scripts/preinstall.mjs", @@ -29,6 +30,7 @@ "@types/node": "^24.19.0", "@types/react": "^19", "@types/react-dom": "^19", + "@vitest/coverage-v8": "^5.0.3", "eslint": "^9", "eslint-config-next": "16.3.8", "husky": "^9.1.7", @@ -36,7 +38,8 @@ "prettier": "^3.9.9", "prettier-plugin-tailwindcss": "^0.8.1", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^5.0.3" }, "lint-staged": { "*": "prettier --write --ignore-unknown" diff --git a/packages/core/node_modules/zod b/packages/core/node_modules/zod new file mode 120000 index 0000000..a16ead0 --- /dev/null +++ b/packages/core/node_modules/zod @@ -0,0 +1 @@ +../../../node_modules/.pnpm/zod@4.6.5/node_modules/zod \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..c52bc58 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,15 @@ +{ + "name": "@costexplorer/core", + "version": "0.0.0", + "private": true, + "description": "FOCUS-aligned cost schema, money math and the Finding contract.", + "license": "Apache-2.0", + "type": "module", + "exports": "./src/index.ts", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "zod": "^4.6.5" + } +} diff --git a/packages/core/src/cost-record.test.ts b/packages/core/src/cost-record.test.ts new file mode 100644 index 0000000..40c362d --- /dev/null +++ b/packages/core/src/cost-record.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { costRecordSchema } from "./cost-record.ts"; + +const usage = { + providerName: "AWS", + billingAccountId: "111111111111", + chargePeriodStart: "2026-09-01T00:00:00Z", + chargePeriodEnd: "2026-09-01T01:00:00Z", + chargeCategory: "Usage", + billingCurrency: "USD", + billedCost: 0.011, + effectiveCost: 0.011, + serviceName: "Amazon Elastic Block Store", +}; + +const issuePaths = (input: unknown) => + costRecordSchema.safeParse(input).error?.issues.map((issue) => issue.path.join(".")) ?? []; + +describe("costRecordSchema", () => { + it("accepts the required FOCUS columns and defaults tags to none", () => { + expect(costRecordSchema.parse(usage)).toEqual({ ...usage, tags: {} }); + }); + + it("accepts the optional columns", () => { + const full = { + ...usage, + subAccountId: "222222222222", + chargeDescription: "$0.08 per GB-month of gp3 storage", + listCost: 0.011, + serviceCategory: "Storage", + regionId: "us-east-1", + resourceId: "vol-0abc", + resourceType: "Volume", + skuId: "ABC123", + pricingCategory: "Standard", + consumedQuantity: 0.1389, + consumedUnit: "GB-Mo", + commitmentDiscountId: "sp-1", + tags: { team: "data" }, + }; + expect(costRecordSchema.parse(full)).toEqual(full); + }); + + it("accepts a negative cost, which is how a credit arrives", () => { + const credit = { ...usage, chargeCategory: "Credit", billedCost: -5, effectiveCost: -5 }; + expect(costRecordSchema.parse(credit).billedCost).toBe(-5); + }); + + it("names the column that is wrong", () => { + expect(issuePaths({ ...usage, chargeCategory: "Refund" })).toEqual(["chargeCategory"]); + expect(issuePaths({ ...usage, billingCurrency: "usd" })).toEqual(["billingCurrency"]); + expect(issuePaths({ ...usage, billedCost: Number.NaN })).toEqual(["billedCost"]); + expect(issuePaths({ ...usage, chargePeriodStart: "2026-09-01" })).toEqual([ + "chargePeriodStart", + ]); + expect(issuePaths({ ...usage, serviceName: "" })).toEqual(["serviceName"]); + }); + + it("rejects a charge period that does not move forward", () => { + expect(issuePaths({ ...usage, chargePeriodEnd: usage.chargePeriodStart })).toEqual([ + "chargePeriodEnd", + ]); + }); +}); diff --git a/packages/core/src/cost-record.ts b/packages/core/src/cost-record.ts new file mode 100644 index 0000000..9ba7071 --- /dev/null +++ b/packages/core/src/cost-record.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +/** + * One charge line, named after the columns of the FinOps FOCUS specification in camelCase. + * `billedCost` is FOCUS `BilledCost`, and so on. Every provider adapter produces this shape, so + * an analyzer never reads a provider's own export format. + * + * This is the subset of FOCUS the analyzers use. Add a column here when an analyzer needs it. + */ +export const chargeCategorySchema = z.enum(["Usage", "Purchase", "Tax", "Credit", "Adjustment"]); +export type ChargeCategory = z.infer; + +export const pricingCategorySchema = z.enum(["Standard", "Dynamic", "Committed", "Other"]); +export type PricingCategory = z.infer; + +const name = z.string().min(1); +const cost = z.number().finite(); +const instant = z.iso.datetime({ offset: true }); + +export const costRecordSchema = z + .object({ + providerName: name, + billingAccountId: name, + subAccountId: name.optional(), + /** Inclusive start of the period the charge covers, ISO 8601. */ + chargePeriodStart: instant, + /** Exclusive end of the period the charge covers, ISO 8601. */ + chargePeriodEnd: instant, + chargeCategory: chargeCategorySchema, + chargeDescription: z.string().optional(), + /** ISO 4217 code. Every cost on the record is in this currency. */ + billingCurrency: z.string().regex(/^[A-Z]{3}$/), + /** What the invoice charges, after discounts. Negative for a credit. */ + billedCost: cost, + /** The amortized cost, with upfront commitment purchases spread over their term. */ + effectiveCost: cost, + /** The cost at the public on-demand price, before any discount. */ + listCost: cost.optional(), + serviceName: name, + serviceCategory: name.optional(), + regionId: name.optional(), + resourceId: name.optional(), + resourceType: name.optional(), + skuId: name.optional(), + pricingCategory: pricingCategorySchema.optional(), + consumedQuantity: z.number().finite().optional(), + consumedUnit: name.optional(), + commitmentDiscountId: name.optional(), + tags: z.record(z.string(), z.string()).default({}), + }) + .refine((record) => Date.parse(record.chargePeriodEnd) > Date.parse(record.chargePeriodStart), { + path: ["chargePeriodEnd"], + message: "chargePeriodEnd must be after chargePeriodStart", + }); + +export type CostRecord = z.infer; diff --git a/packages/core/src/finding.test.ts b/packages/core/src/finding.test.ts new file mode 100644 index 0000000..b3d35c0 --- /dev/null +++ b/packages/core/src/finding.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { findingSchema, rankFindings, totalMonthlySavingUsd, type Finding } from "./finding.ts"; + +const finding = (overrides: Partial = {}): Finding => ({ + analyzerId: "aws.ebs.unattached", + resourceId: "vol-0abc", + account: "111111111111", + region: "us-east-1", + monthlySavingUsd: 8, + confidence: "high", + evidence: { sizeGb: 100, daysUnattached: 21 }, + remediation: { cli: "aws ec2 delete-volume --volume-id vol-0abc" }, + ...overrides, +}); + +const issuePaths = (input: unknown) => + findingSchema.safeParse(input).error?.issues.map((issue) => issue.path.join(".")) ?? []; + +describe("findingSchema", () => { + it("accepts a finding and returns it unchanged", () => { + expect(findingSchema.parse(finding())).toEqual(finding()); + }); + + it("accepts a finding with no remediation snippet", () => { + expect(findingSchema.parse(finding({ remediation: {} })).remediation).toEqual({}); + }); + + it("rejects a saving that is negative or not a number", () => { + expect(issuePaths(finding({ monthlySavingUsd: -1 }))).toEqual(["monthlySavingUsd"]); + expect(issuePaths(finding({ monthlySavingUsd: Number.NaN }))).toEqual(["monthlySavingUsd"]); + }); + + it("rejects an analyzer id that is not provider.service.rule", () => { + expect(issuePaths(finding({ analyzerId: "unattached" }))).toEqual(["analyzerId"]); + expect(issuePaths(finding({ analyzerId: "AWS.EBS.Unattached" }))).toEqual(["analyzerId"]); + }); + + it("rejects an unknown confidence and an empty resource", () => { + expect(issuePaths({ ...finding(), confidence: "certain" })).toEqual(["confidence"]); + expect(issuePaths(finding({ resourceId: "" }))).toEqual(["resourceId"]); + }); +}); + +describe("rankFindings", () => { + it("puts the largest saving first and leaves the input alone", () => { + const input = [ + finding({ resourceId: "small", monthlySavingUsd: 3 }), + finding({ resourceId: "large", monthlySavingUsd: 400 }), + finding({ resourceId: "medium", monthlySavingUsd: 32.85 }), + ]; + expect(rankFindings(input).map((f) => f.resourceId)).toEqual(["large", "medium", "small"]); + expect(input.map((f) => f.resourceId)).toEqual(["small", "large", "medium"]); + }); + + it("breaks a tie by analyzer, then by resource", () => { + const input = [ + finding({ analyzerId: "aws.ec2.eip-unused", resourceId: "b" }), + finding({ analyzerId: "aws.ebs.unattached", resourceId: "b" }), + finding({ analyzerId: "aws.ebs.unattached", resourceId: "a" }), + finding({ analyzerId: "aws.ebs.unattached", resourceId: "a" }), + ]; + expect(rankFindings(input).map((f) => `${f.analyzerId} ${f.resourceId}`)).toEqual([ + "aws.ebs.unattached a", + "aws.ebs.unattached a", + "aws.ebs.unattached b", + "aws.ec2.eip-unused b", + ]); + expect(rankFindings(input.toReversed())).toEqual(rankFindings(input)); + }); +}); + +describe("totalMonthlySavingUsd", () => { + it("adds the savings without float drift", () => { + const input = [finding({ monthlySavingUsd: 0.1 }), finding({ monthlySavingUsd: 0.2 })]; + expect(totalMonthlySavingUsd(input)).toBe(0.3); + expect(totalMonthlySavingUsd([])).toBe(0); + }); +}); diff --git a/packages/core/src/finding.ts b/packages/core/src/finding.ts new file mode 100644 index 0000000..195795f --- /dev/null +++ b/packages/core/src/finding.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import { sumUsd } from "./money.ts"; + +export const confidenceSchema = z.enum(["high", "medium", "low"]); +export type Confidence = z.infer; + +/** + * What an analyzer reports: one resource, what it wastes each month and how to stop. This is the + * public contract between the engine and everything that shows findings. + */ +export const findingSchema = z.object({ + /** Provider, service and rule, such as `aws.ebs.unattached`. */ + analyzerId: z.string().regex(/^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*)+$/), + resourceId: z.string().min(1), + account: z.string().min(1), + region: z.string().min(1), + /** A conservative estimate. When unsure, an analyzer reports the lower figure. */ + monthlySavingUsd: z.number().finite().nonnegative(), + confidence: confidenceSchema, + /** The facts the estimate rests on, so a reader can check the math. */ + evidence: z.record(z.string(), z.unknown()), + remediation: z.object({ + cli: z.string().min(1).optional(), + terraform: z.string().min(1).optional(), + console: z.string().min(1).optional(), + }), +}); + +export type Finding = z.infer; + +const byText = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); + +/** + * Largest saving first. Ties fall back to analyzer and resource so the same input always gives + * the same order. + */ +export function rankFindings(findings: readonly Finding[]): Finding[] { + return [...findings].sort( + (a, b) => + b.monthlySavingUsd - a.monthlySavingUsd || + byText(a.analyzerId, b.analyzerId) || + byText(a.resourceId, b.resourceId), + ); +} + +export function totalMonthlySavingUsd(findings: readonly Finding[]): number { + return sumUsd(findings.map((finding) => finding.monthlySavingUsd)); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..d52b9ff --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,17 @@ +export { + chargeCategorySchema, + costRecordSchema, + pricingCategorySchema, + type ChargeCategory, + type CostRecord, + type PricingCategory, +} from "./cost-record.ts"; +export { + confidenceSchema, + findingSchema, + rankFindings, + totalMonthlySavingUsd, + type Confidence, + type Finding, +} from "./finding.ts"; +export { floorToCents, HOURS_PER_MONTH, monthlyFromHourly, sumUsd } from "./money.ts"; diff --git a/packages/core/src/money.test.ts b/packages/core/src/money.test.ts new file mode 100644 index 0000000..021b3e3 --- /dev/null +++ b/packages/core/src/money.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { floorToCents, HOURS_PER_MONTH, monthlyFromHourly, sumUsd } from "./money.ts"; + +describe("sumUsd", () => { + it("adds sub-cent amounts without float drift", () => { + expect(0.1 + 0.2).not.toBe(0.3); + expect(sumUsd([0.1, 0.2])).toBe(0.3); + expect(sumUsd(Array.from({ length: 10_000 }, () => 0.0001))).toBe(1); + }); + + it("returns zero for no amounts and keeps credits negative", () => { + expect(sumUsd([])).toBe(0); + expect(sumUsd([10, -2.5])).toBe(7.5); + }); + + it("rejects a value that is not a dollar amount", () => { + expect(() => sumUsd([1, Number.NaN])).toThrow(RangeError); + expect(() => sumUsd([Number.POSITIVE_INFINITY])).toThrow("Not a dollar amount: Infinity"); + }); +}); + +describe("monthlyFromHourly", () => { + it("prices a month at 730 hours", () => { + expect(HOURS_PER_MONTH).toBe(730); + expect(monthlyFromHourly(0.005)).toBe(3.65); + expect(monthlyFromHourly(0.045)).toBe(32.85); + }); +}); + +describe("floorToCents", () => { + it("rounds a saving down, never up", () => { + expect(floorToCents(12.349)).toBe(12.34); + expect(floorToCents(12.999999)).toBe(12.99); + }); + + it("keeps an amount that is already whole cents", () => { + // 0.29 * 100 is 28.999999999999996 in floating point. + expect(floorToCents(0.29)).toBe(0.29); + expect(floorToCents(8)).toBe(8); + }); +}); diff --git a/packages/core/src/money.ts b/packages/core/src/money.ts new file mode 100644 index 0000000..a829e66 --- /dev/null +++ b/packages/core/src/money.ts @@ -0,0 +1,28 @@ +/** Hours in an average month, 8,760 / 12. AWS and GCP quote monthly prices on this basis. */ +export const HOURS_PER_MONTH = 730; + +const MICROS_PER_USD = 1_000_000; + +function toMicros(usd: number): number { + if (!Number.isFinite(usd)) throw new RangeError(`Not a dollar amount: ${usd}`); + return Math.round(usd * MICROS_PER_USD); +} + +/** + * Adds dollar amounts in whole micro-dollars. A cost export holds thousands of sub-cent line + * items, and adding them as floats drifts. + */ +export function sumUsd(amounts: Iterable): number { + let micros = 0; + for (const amount of amounts) micros += toMicros(amount); + return micros / MICROS_PER_USD; +} + +export function monthlyFromHourly(hourlyUsd: number): number { + return toMicros(hourlyUsd * HOURS_PER_MONTH) / MICROS_PER_USD; +} + +/** Rounds down to the cent. A saving is an estimate, so it is never rounded up. */ +export function floorToCents(usd: number): number { + return Math.floor(toMicros(usd) / 10_000) / 100; +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..1d4d90f --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.packages.json", + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70cd999..5b019b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,6 +191,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.3.0(@types/react@19.3.0) + '@vitest/coverage-v8': + specifier: ^5.0.3 + version: 5.0.3(vitest@5.0.3) eslint: specifier: ^9 version: 9.39.5(jiti@2.7.0)(supports-color@7.2.0) @@ -215,6 +218,15 @@ importers: typescript: specifier: ^5 version: 5.9.3 + vitest: + specifier: ^5.0.3 + version: 5.0.3(@types/node@24.19.0)(@vitest/coverage-v8@5.0.3)(vite@8.3.1(@types/node@24.19.0)(jiti@2.7.0)(yaml@2.9.1)) + + packages/core: + dependencies: + zod: + specifier: ^4.6.5 + version: 4.6.5 packages: @@ -289,6 +301,10 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -642,6 +658,108 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@oxc-project/types@0.152.0': + resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} + + '@rolldown/binding-android-arm-eabi@1.2.12': + resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.12': + resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.12': + resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.12': + resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.12': + resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.12': + resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.12': + resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.12': + resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.12': + resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.12': + resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -743,6 +861,12 @@ packages: '@tybys/wasm-util@0.10.4': resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -942,6 +1066,37 @@ packages: cpu: [x64] os: [win32] + '@vitest/coverage-v8@5.0.3': + resolution: {integrity: sha512-+klsyz7BvT1vCU28Zkfzms1Ia78XD0V41U3FUtRaa3S+vOr/EXvK1O6BvVD7l1RzEjm6SONR2aybOvDDf9u0mQ==} + peerDependencies: + '@vitest/browser': 5.0.3 + vitest: 5.0.3 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/istanbul-lib-coverage@1.0.2': + resolution: {integrity: sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-report@1.0.2': + resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==} + engines: {node: '>=22'} + + '@vitest/mocker@5.0.3': + resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.3': + resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -998,9 +1153,16 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + ast-v8-to-istanbul@1.0.7: + resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -1064,6 +1226,10 @@ packages: caniuse-lite@1.0.30001814: resolution: {integrity: sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==} + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1176,6 +1342,9 @@ packages: resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -1317,10 +1486,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + 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'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1369,6 +1545,11 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1608,6 +1789,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1662,30 +1846,60 @@ packages: cpu: [arm64] os: [android] + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.32.0: resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.32.0: resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.32.0: resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.32.0: resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} @@ -1693,6 +1907,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} @@ -1700,6 +1921,13 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} @@ -1707,6 +1935,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} @@ -1714,22 +1949,45 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.32.0: resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss@1.32.0: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + lint-staged@17.6.0: resolution: {integrity: sha512-nOhBfYkZEwkFtNzD5pK9s9LkcWoxJ+whLzEnhqi9nl7zTB5n7tg8+9iYgMGx2Wm3VlLu8cbNj0RACzY+nWi5iQ==} engines: {node: '>=22.22.1'} @@ -1752,6 +2010,12 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1851,6 +2115,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2015,6 +2283,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rolldown@1.2.12: + resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2094,6 +2367,9 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -2161,6 +2437,10 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tinybench@6.2.0: + resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} + engines: {node: '>=20.0.0'} + tinyexec@1.3.1: resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} @@ -2169,6 +2449,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2236,6 +2520,90 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vite@8.3.1: + resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.3: + resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.3 + '@vitest/browser-preview': 5.0.3 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.3 + '@vitest/coverage-v8': 5.0.3 + '@vitest/ui': 5.0.3 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + 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-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2257,6 +2625,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2386,6 +2759,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@1.0.2': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -2665,6 +3040,55 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@oxc-project/types@0.152.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.12': + optional: true + + '@rolldown/binding-android-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-x64@1.2.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.12': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.12': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@rtsao/scc@1.1.0': {} '@swc/helpers@0.5.23': @@ -2745,6 +3169,13 @@ snapshots: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} @@ -2924,6 +3355,35 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true + '@vitest/coverage-v8@5.0.3(vitest@5.0.3)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/istanbul-lib-coverage': 1.0.2 + '@vitest/istanbul-lib-report': 1.0.2 + ast-v8-to-istanbul: 1.0.7 + magicast: 0.5.5 + obug: 2.2.1 + std-env: 4.3.0 + tinyrainbow: 3.2.0 + vitest: 5.0.3(@types/node@24.19.0)(@vitest/coverage-v8@5.0.3)(vite@8.3.1(@types/node@24.19.0)(jiti@2.7.0)(yaml@2.9.1)) + + '@vitest/istanbul-lib-coverage@1.0.2': {} + + '@vitest/istanbul-lib-report@1.0.2': + dependencies: + '@vitest/istanbul-lib-coverage': 1.0.2 + + '@vitest/mocker@5.0.3(vite@8.3.1(@types/node@24.19.0)(jiti@2.7.0)(yaml@2.9.1))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.3 + estree-walker: 3.0.3 + magic-string: 1.4.2 + optionalDependencies: + vite: 8.3.1(@types/node@24.19.0)(jiti@2.7.0)(yaml@2.9.1) + + '@vitest/spy@5.0.3': {} + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: acorn: 8.18.0 @@ -3012,8 +3472,16 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} + ast-v8-to-istanbul@1.0.7: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-function@1.0.0: {} available-typed-arrays@1.0.7: @@ -3072,6 +3540,8 @@ snapshots: caniuse-lite@1.0.30001814: {} + chai@6.3.0: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -3251,6 +3721,8 @@ snapshots: iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 + es-module-lexer@2.3.2: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -3482,8 +3954,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} + expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -3530,6 +4008,9 @@ snapshots: dependencies: is-callable: 1.2.7 + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.2.0: @@ -3777,6 +4258,8 @@ snapshots: jiti@2.7.0: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.3.2: @@ -3822,36 +4305,69 @@ snapshots: lightningcss-android-arm64@1.32.0: optional: true + lightningcss-android-arm64@1.33.0: + optional: true + lightningcss-darwin-arm64@1.32.0: optional: true + lightningcss-darwin-arm64@1.33.0: + optional: true + lightningcss-darwin-x64@1.32.0: optional: true + lightningcss-darwin-x64@1.33.0: + optional: true + lightningcss-freebsd-x64@1.32.0: optional: true + lightningcss-freebsd-x64@1.33.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + lightningcss-linux-arm64-gnu@1.32.0: optional: true + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + lightningcss-linux-arm64-musl@1.32.0: optional: true + lightningcss-linux-arm64-musl@1.33.0: + optional: true + lightningcss-linux-x64-gnu@1.32.0: optional: true + lightningcss-linux-x64-gnu@1.33.0: + optional: true + lightningcss-linux-x64-musl@1.32.0: optional: true + lightningcss-linux-x64-musl@1.33.0: + optional: true + lightningcss-win32-arm64-msvc@1.32.0: optional: true + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + lightningcss-win32-x64-msvc@1.32.0: optional: true + lightningcss-win32-x64-msvc@1.33.0: + optional: true + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 @@ -3868,6 +4384,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + lint-staged@17.6.0: dependencies: picomatch: 4.0.7 @@ -3894,6 +4426,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + source-map-js: 1.2.2 + math-intrinsics@1.1.0: {} merge2@1.4.1: {} @@ -3997,6 +4539,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + obug@2.2.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -4115,6 +4659,27 @@ snapshots: reusify@1.1.0: {} + rolldown@1.2.12: + dependencies: + '@oxc-project/types': 0.152.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.12 + '@rolldown/binding-android-arm64': 1.2.12 + '@rolldown/binding-darwin-arm64': 1.2.12 + '@rolldown/binding-darwin-x64': 1.2.12 + '@rolldown/binding-freebsd-x64': 1.2.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.12 + '@rolldown/binding-linux-arm64-gnu': 1.2.12 + '@rolldown/binding-linux-arm64-musl': 1.2.12 + '@rolldown/binding-linux-ppc64-gnu': 1.2.12 + '@rolldown/binding-linux-s390x-gnu': 1.2.12 + '@rolldown/binding-linux-x64-gnu': 1.2.12 + '@rolldown/binding-linux-x64-musl': 1.2.12 + '@rolldown/binding-openharmony-arm64': 1.2.12 + '@rolldown/binding-win32-arm64-msvc': 1.2.12 + '@rolldown/binding-win32-x64-msvc': 1.2.12 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -4238,6 +4803,8 @@ snapshots: stable-hash@0.0.5: {} + std-env@4.3.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -4317,6 +4884,8 @@ snapshots: tapable@2.3.3: {} + tinybench@6.2.0: {} + tinyexec@1.3.1: {} tinyglobby@0.2.17: @@ -4324,6 +4893,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tinyrainbow@3.2.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -4436,6 +5007,41 @@ snapshots: dependencies: punycode: 2.3.1 + vite@8.3.1(@types/node@24.19.0)(jiti@2.7.0)(yaml@2.9.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.12 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.19.0 + fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.1 + + vitest@5.0.3(@types/node@24.19.0)(@vitest/coverage-v8@5.0.3)(vite@8.3.1(@types/node@24.19.0)(jiti@2.7.0)(yaml@2.9.1)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.3(vite@8.3.1(@types/node@24.19.0)(jiti@2.7.0)(yaml@2.9.1)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.1(@types/node@24.19.0)(jiti@2.7.0)(yaml@2.9.1) + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 24.19.0 + '@vitest/coverage-v8': 5.0.3(vitest@5.0.3) + transitivePeerDependencies: + - msw + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -4481,6 +5087,8 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@3.2.1: {} + word-wrap@1.2.5: {} yallist@3.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c2da90e..6eb71cf 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,5 @@ +packages: + - packages/* allowBuilds: sharp: false unrs-resolver: false diff --git a/scripts/checks/lint-boundaries.test.mjs b/scripts/checks/lint-boundaries.test.mjs index eff8942..83a231e 100644 --- a/scripts/checks/lint-boundaries.test.mjs +++ b/scripts/checks/lint-boundaries.test.mjs @@ -40,3 +40,12 @@ test("process.env is rejected anywhere in src", async () => { ["no-restricted-properties"], ); }); + +test("an engine package may not import React or Next.js", async () => { + const code = 'import "react";\nimport "next/link";\nimport "zod";\n'; + assert.deepEqual(await ruleIds("packages/core/src/example.ts", code), [ + "no-restricted-imports", + "no-restricted-imports", + ]); + assert.deepEqual(await ruleIds("packages/ui/src/example.tsx", 'import "react";\n'), []); +}); diff --git a/tsconfig.json b/tsconfig.json index cf9c65d..a9ac38f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,5 +30,5 @@ ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", "packages"] } diff --git a/tsconfig.packages.json b/tsconfig.packages.json new file mode 100644 index 0000000..1ac25c7 --- /dev/null +++ b/tsconfig.packages.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "nodenext", + "moduleResolution": "nodenext", + "types": [], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true + } +} diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..855688c --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["packages/*/src/**/*.test.ts"], + coverage: { + provider: "v8", + include: ["packages/*/src/**/*.ts"], + exclude: ["**/*.test.ts", "**/index.ts"], + reporter: ["text"], + thresholds: { statements: 90, branches: 90, functions: 90, lines: 90 }, + }, + }, +});