Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pnpm-lock.yaml
coverage
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/`. 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/<name>/src/` with its test beside it as `<file>.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

Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand Down
25 changes: 17 additions & 8 deletions docs/engineering-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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%)
```
3 changes: 3 additions & 0 deletions docs/guardrails.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
14 changes: 14 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -64,13 +70,21 @@ 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:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
"coverage/**",
]),
]);

Expand Down
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -29,14 +30,16 @@
"@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",
"lint-staged": "^17.6.0",
"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"
Expand Down
1 change: 1 addition & 0 deletions packages/core/node_modules/zod

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
64 changes: 64 additions & 0 deletions packages/core/src/cost-record.test.ts
Original file line number Diff line number Diff line change
@@ -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",
]);
});
});
56 changes: 56 additions & 0 deletions packages/core/src/cost-record.ts
Original file line number Diff line number Diff line change
@@ -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<typeof chargeCategorySchema>;

export const pricingCategorySchema = z.enum(["Standard", "Dynamic", "Committed", "Other"]);
export type PricingCategory = z.infer<typeof pricingCategorySchema>;

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<typeof costRecordSchema>;
Loading
Loading