Make sure your app shows the right data.

Samethru compares what’s saved in your database with what your app actually shows on screen. When they don’t match, it shows you where it went wrong and which line of code to look at.

npx samethru init

It works inside the AI coding tool you already use: Claude Code, Cursor, Codex or Gemini CLI. Free, MIT license.

Example: what Samethru shows you when a price on a page is wrong.

Lighting

Walnut Desk Lamp

Price
$24.99
Sale price
$1,999.00
Brand
Lumen Works
Rating
4.3 / 5 (17 reviews)
Stock
12 in stock

Wrong sale price

Saved
$19.99 (1999 cents)
Sent
$19.99 (1999 cents)
Shown
$1,999.00

Cents shown as dollars: 100 times too big.

Look at public/assets/pages/product.js:16

The marks show which values Samethru checked. The name and the price match everywhere. The sale price doesn’t. The values without a mark weren’t checked.

How it checks a value

Every value on your screen makes a trip. It’s saved in your database, sent by your server (the API), and shown on the page. Samethru reads it at all three stops and compares them. Here is the sale price from the example above.

Saved in the database

main: SELECT sale_price_cents FROM products WHERE id = ? with [7] → sale_price_cents

1999cents, so $19.99

Sent by the API

GET /api/products/7 → $.sale_price_cents (200, as the page received it)

1999cents, so $19.99

It goes wrong here, between sent and shown, so the bug is in the code that draws the page.

Shown on the page

/products/7 → [data-testid=sale-price] (1 matched)

$1,999.00100 times too big

The bug: cents shown as dollars

The price is saved in cents (1999), but the page treats that number as dollars, so $19.99 becomes $1,999.00. Samethru names the kind of bug, shows the value at each stop, and points to the line of code that shows it:

public/assets/pages/product.js:16
14  product.sale_price_cents === null
15    ? ''
16    : `<dt>Sale price</dt><dd data-testid="sale-price">${formatMoney(product.sale_price_cents)}</dd>`

It also saves the proof on your computer: a screenshot of the price, the page’s HTML and what the server sent, in .samethru/runs/.

What you get back

A real Samethru report, exactly as it printed, shown the way your AI coding tool shows it. It ran 20 checks across 12 pages of a shop app: 12 found a wrong value, and 8 confirmed the value was right. The parts left out are marked.

Once the checks are saved, you can run them again any time with one command and no AI, for example every time you push code.

samethru run

npx samethru run

Finished in 14.6 s with exit code 1

Samethru report

Run 2026-10-04T06-36-54-175Z-c0f5 · 2026-10-04 06:37:08 UTC · http://127.0.0.1:4321

12 FAIL · 8 PASS · 0 NOT_RUN · 0 INCONCLUSIVE (20 checks)

Not every check passed.

Coverage: a check read 20 of 77 values on 12 pages (26%) and 21 of 102 API fields on 13 endpoints (21%). Values no check reads are not verified.

Failures (12)

FAIL cart-total: Cart total

Rounding error (strong): The page shows 35.79 but the API has 35.80: off by 0.01, one unit (0.01) in the last place shown. That is the typical result of adding up floating-point amounts or truncating instead of rounding.

Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).

Layer Value From Read at
Database 3580 → 35.80 main: SELECT SUM(ci.quantity * p.price_cents) AS total FROM cart_items ci JOIN products p ON p.id = ci.product_id → total 2026-10-04 06:36:54 UTC
API 3580 → 35.80 GET /api/cart → $.total_cents (200, as the page received it) 2026-10-04 06:36:54 UTC
Page "$35.79" → 35.79 /cart → [data-testid=cart-total] (1 matched) 2026-10-04 06:36:59 UTC

Code: page public/assets/pages/cart.js:28 · api src/routes.js:113 · db db/schema.sql:15

Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/cart-total.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-cart.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/cart-total.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/cart-total.html

Cut from the output: 176 lines: the other 11 failures, each with its evidence table

Not run (0)

None.

Inconclusive (0)

None.

Passed (8)

  • customer-4-orders: Dana Ruiz's orders, newest first
  • customers-list: Customer list, first page, newest first
  • dashboard-customer-count: Number of customers on the dashboard
  • dashboard-last-restock: Time of the last restock on the dashboard
  • order-1001-total: Total of order 1001
  • order-1002-status: Status of order 1002
  • product-7-name: Name of product 7
  • product-7-price: Price of the Walnut Desk Lamp

Cut from the output: 125 lines: the coverage section, every unchecked value by name

Report: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/report.md (and run.json)

the whole report, 349 lines

Samethru report

Run 2026-10-04T06-36-54-175Z-c0f5 · 2026-10-04 06:37:08 UTC · http://127.0.0.1:4321

12 FAIL · 8 PASS · 0 NOT_RUN · 0 INCONCLUSIVE (20 checks)

Not every check passed.

Coverage: a check read 20 of 77 values on 12 pages (26%) and 21 of 102 API fields on 13 endpoints (21%). Values no check reads are not verified.

Failures (12)

FAIL cart-total: Cart total

Rounding error (strong): The page shows 35.79 but the API has 35.80: off by 0.01, one unit (0.01) in the last place shown. That is the typical result of adding up floating-point amounts or truncating instead of rounding.

Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).

Layer Value From Read at
Database 3580 → 35.80 main: SELECT SUM(ci.quantity * p.price_cents) AS total FROM cart_items ci JOIN products p ON p.id = ci.product_id → total 2026-10-04 06:36:54 UTC
API 3580 → 35.80 GET /api/cart → $.total_cents (200, as the page received it) 2026-10-04 06:36:54 UTC
Page "$35.79" → 35.79 /cart → [data-testid=cart-total] (1 matched) 2026-10-04 06:36:59 UTC

Code: page public/assets/pages/cart.js:28 · api src/routes.js:113 · db db/schema.sql:15

Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/cart-total.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-cart.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/cart-total.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/cart-total.html

FAIL dashboard-low-stock: Number of low-stock products on the dashboard

Stale cached value (strong): The API serves 4 but the database has 2. The response was generated at 2026-10-04 06:36:34 UTC (from Date − Age, accurate to the second) and the data changed at 2026-10-04 06:36:36.164 UTC, about 2.2 s later. Asking caches to revalidate didn't help, so a server-side cache (in memory, Redis or a reverse proxy) is serving the old value.

Where it first goes wrong: Database → API, so look in the backend (between the database and the API).

Layer Value From Read at
Database 2 main: SELECT COUNT(*) AS n FROM products WHERE stock < 5 AND active = 1 → n 2026-10-04 06:37:01 UTC
API 4 GET /api/stats → $.low_stock_count (200, as the page received it) 2026-10-04 06:37:01 UTC
Page "4" → 4 / → [data-testid=low-stock-count] (1 matched) 2026-10-04 06:37:01 UTC

Code: page public/assets/pages/dashboard.js:19 · api src/stats-cache.js:10 · db db/schema.sql:17

Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/dashboard-low-stock.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-home.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/dashboard-low-stock.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/dashboard-low-stock.html

FAIL lighting-result-count: Number of results in the Lighting category

Wrong count after filtering (strong): The page shows 23 for the filter category = lighting, but only 5 match. 23 is the total without the filter, so the count ignores the filter.

Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).

Layer Value From Read at
Database 5 main: SELECT COUNT(*) AS n FROM products p JOIN categories c ON c.id = p.category_id WHERE p.active = 1 AND c.slug = ? with ["lighting"] → n 2026-10-04 06:37:01 UTC
API 5 GET /api/products?category=lighting → $.total (200, as the page received it) 2026-10-04 06:37:01 UTC
Page "23 results" → 23 /products?category=lighting → [data-testid=result-count] (1 matched) 2026-10-04 06:37:02 UTC

Code: page public/assets/pages/products.js:32 · api src/routes.js:60 · db db/schema.sql:10

Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/lighting-result-count.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-products-category-lighting.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/lighting-result-count.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/lighting-result-count.html

FAIL order-1001-delivery-date: Delivery date of order 1001

Date off by one day (strong): The page shows 2026-03-16, the day before the API's 2026-03-17. A date without a time is read as midnight UTC, and in America/New_York (UTC−4) that moment is still the previous evening.

Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).

Layer Value From Read at
Database "2026-03-17" → 2026-03-17 main: SELECT delivery_date FROM orders WHERE id = ? with [1001] → delivery_date 2026-10-04 06:37:02 UTC
API "2026-03-17" → 2026-03-17 GET /api/orders/1001 → $.delivery_date (200, as the page received it) 2026-10-04 06:37:02 UTC
Page "March 16, 2026" → 2026-03-16 /orders/1001 → [data-testid=delivery-date] (1 matched) 2026-10-04 06:37:03 UTC

Code: page public/assets/pages/order.js:28 · api src/routes.js:130 · db db/schema.sql:36

Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/order-1001-delivery-date.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-orders-1001.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/order-1001-delivery-date.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/order-1001-delivery-date.html

FAIL order-1001-placed-at: When order 1001 was placed

Timezone shift (strong): The page's time is 4 h later than the API's (2026-03-14T22:30:00Z vs 2026-03-14T18:30:00Z). That is exactly the UTC offset of America/New_York (UTC−4) on that date, so a UTC time is being shown as if it were local time, or the reverse.

Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).

Layer Value From Read at
Database "2026-03-14 18:30:00" → 2026-03-14T18:30:00Z main: SELECT placed_at FROM orders WHERE id = ? with [1001] → placed_at 2026-10-04 06:37:03 UTC
API "2026-03-14T18:30:00Z" → 2026-03-14T18:30:00Z GET /api/orders/1001 → $.placed_at (200, as the page received it) 2026-10-04 06:37:03 UTC
Page "Mar 14, 2026, 6:30 PM" → 2026-03-14T22:30:00Z /orders/1001 → [data-testid=placed-at] (1 matched) 2026-10-04 06:37:03 UTC

Code: page public/assets/pages/order.js:26 · api src/routes.js:130 · db db/schema.sql:35

Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/order-1001-placed-at.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-orders-1001.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/order-1001-placed-at.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/order-1001-placed-at.html

FAIL order-1002-discount: Discount on order 1002

Wrong decimal places (strong): The page shows 12.5 with 1 decimal place; it should show 2. The number is right, but it is formatted by hand (for example "$" + amount) instead of with a number formatter.

Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).

Layer Value From Read at
Database 1250 → 12.50 main: SELECT discount_cents FROM orders WHERE id = ? with [1002] → discount_cents 2026-10-04 06:37:03 UTC
API 1250 → 12.50 GET /api/orders/1002 → $.discount_cents (200, as the page received it) 2026-10-04 06:37:03 UTC
Page "$12.5" → 12.5 /orders/1002 → [data-testid=discount] (1 matched) 2026-10-04 06:37:04 UTC

Code: page public/assets/pages/order.js:49 · api src/routes.js:130 · db db/schema.sql:37

Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/order-1002-discount.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-orders-1002.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/order-1002-discount.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/order-1002-discount.html

FAIL order-1003-status: Status of order 1003

Wrong label for a status (strong): The page shows "Cancelled" for "refunded", but "Cancelled" is the label for "cancelled". The label for "refunded" should be "Refunded": the label map has a wrong entry.

Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).

Layer Value From Read at
Database "refunded" → "Refunded" main: SELECT status FROM orders WHERE id = ? with [1003] → status 2026-10-04 06:37:04 UTC
API "refunded" → "Refunded" GET /api/orders/1003 → $.status (200, as the page received it) 2026-10-04 06:37:04 UTC
Page "Cancelled" /orders/1003 → [data-testid=status] (1 matched) 2026-10-04 06:37:04 UTC

Code: page public/assets/pages/order.js:24 · api src/routes.js:130 · db db/schema.sql:34 · labels src/order-status.js:8

Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/order-1003-status.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-orders-1003.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/order-1003-status.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/order-1003-status.html

FAIL orders-list-order: Orders are listed newest first

Sort order differs between layers (strong): The page has the same 12 items as the API, in a different order. At position 1 it shows "1004" where "1012" belongs. Sorting by displayed text (dates sort alphabetically as text) or re-sorting already sorted data does this.

Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).

Layer Value From Read at
Database [{"id":1012},{"id":1011},{"id":1010},{"id":1009},{"id":1008},{"id":1007},{"id":1006},{"id":1005},{"id":1004},{"id":10… → 12 items main: SELECT id FROM orders ORDER BY placed_at DESC → id 2026-10-04 06:37:05 UTC
API [{"id":1012,"customer_name":"Nia Brooks","status":"pending","placed_at":"2026-04-26T18:45:00Z","total_cents":3110},{"… → 12 items GET /api/orders → $.items (200, as the page received it) 2026-10-04 06:37:05 UTC
Page ["#1004","#1003","#1002","#1001","#1007","#1006","#1012","#1011","#1005","#1010","#1009","#1008"] → 12 items /orders → [data-testid=order-row] (12 matched) 2026-10-04 06:37:05 UTC

Code: page public/assets/pages/orders.js:20 · api src/routes.js:117 · db db/schema.sql:35

Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/orders-list-order.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-orders.png

FAIL product-12-brand: Brand of the Stoneware Pour-Over Set

null, undefined or NaN shown as text (strong): The page shows the text "null" where the API has no value (null): a missing value is being written out as text instead of being left empty or shown as a placeholder.

Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).

Layer Value From Read at
Database null → no value (null) main: SELECT brand FROM products WHERE id = ? with [12] → brand 2026-10-04 06:37:05 UTC
API null → no value (null) GET /api/products/12 → $.brand (200, as the page received it) 2026-10-04 06:37:05 UTC
Page "null" /products/12 → [data-testid=brand] (1 matched) 2026-10-04 06:37:06 UTC

Code: page public/assets/pages/product.js:19 · api src/routes.js:87 · db db/schema.sql:14

Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/product-12-brand.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-products-12.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/product-12-brand.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/product-12-brand.html

FAIL product-12-rating: Average rating of the Stoneware Pour-Over Set

Missing value shown as 0 (strong): The page shows 0.0 where the API has no value (null): "no value" is being shown as zero, for example with "?? 0" or "|| 0", which looks like a real number.

Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).

Layer Value From Read at
Database null → no value (null) main: SELECT avg_rating FROM products WHERE id = ? with [12] → avg_rating 2026-10-04 06:37:06 UTC
API null → no value (null) GET /api/products/12 → $.avg_rating (200, as the page received it) 2026-10-04 06:37:06 UTC
Page "0.0" → 0.0 /products/12 → [data-testid=rating] (1 matched) 2026-10-04 06:37:06 UTC

Code: page public/assets/pages/product.js:21 · api src/routes.js:87 · db db/schema.sql:18

Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/product-12-rating.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-products-12.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/product-12-rating.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/product-12-rating.html

FAIL product-7-sale-price: Sale price of the Walnut Desk Lamp

Cents vs dollars (strong): The page shows 1999.00 where the API has 19.99: exactly 100× too large. An amount in minor units (cents) is being shown as if it were in major units (dollars).

Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).

Layer Value From Read at
Database 1999 → 19.99 main: SELECT sale_price_cents FROM products WHERE id = ? with [7] → sale_price_cents 2026-10-04 06:37:07 UTC
API 1999 → 19.99 GET /api/products/7 → $.sale_price_cents (200, as the page received it) 2026-10-04 06:37:07 UTC
Page "$1,999.00" → 1999.00 /products/7 → [data-testid=sale-price] (1 matched) 2026-10-04 06:37:07 UTC

Code: page public/assets/pages/product.js:16 · api src/routes.js:87 · db db/schema.sql:16

Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/product-7-sale-price.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-products-7.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/product-7-sale-price.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/product-7-sale-price.html

FAIL products-list-complete: The product list shows every active product

List cut off by pagination (strong): The page shows 20 of 23 items and nothing leads to the other 3. There is no pager. 20 is a typical page size, so the list stops at the first page of the API's results.

Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).

Layer Value From Read at
Database [{"name":"Brass Cup Hook"},{"name":"Linen Napkin"},{"name":"Ceramic Bud Vase"},{"name":"Paper Pendant Shade"},{"name"… → 23 items main: SELECT name FROM products WHERE active = 1 ORDER BY id → name 2026-10-04 06:37:07 UTC
API [{"id":1,"name":"Brass Cup Hook","category":"decor","price_cents":115,"sale_price_cents":null},{"id":2,"name":"Linen … → 20 items (total 23) GET /api/products → $.items (200, as the page received it) 2026-10-04 06:37:07 UTC
Page ["Brass Cup Hook","Linen Napkin","Ceramic Bud Vase","Paper Pendant Shade","Oak Cutting Board","Wool Throw Blanket","W… → 20 items /products → [data-testid=product-card] (20 matched) 2026-10-04 06:37:08 UTC

Code: page public/assets/pages/products.js:37 · api src/routes.js:60 · db db/schema.sql:10

Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/products-list-complete.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-products.png

Not run (0)

None.

Inconclusive (0)

None.

Passed (8)

  • customer-4-orders: Dana Ruiz's orders, newest first
  • customers-list: Customer list, first page, newest first
  • dashboard-customer-count: Number of customers on the dashboard
  • dashboard-last-restock: Time of the last restock on the dashboard
  • order-1001-total: Total of order 1001
  • order-1002-status: Status of order 1002
  • product-7-name: Name of product 7
  • product-7-price: Price of the Walnut Desk Lamp

Coverage

Values on a page are found as visible text leaves that look like data (digits, currency, dates, percentages) or equal a value in a JSON response the page received; repeated items are grouped. A value is covered when a check reads its element (or one containing it); a repeated value (a list column) counts once and is covered only when every occurrence is read.

/cart: 1 of 5 values (20%)

Not covered:

  • [data-testid="cart-row"] > td:nth-of-type(1) > a: "Brass Cup Hook" (text, shown 3 times, 0 read); in the JSON at GET /api/cart $.items[0].name
  • [data-testid="cart-row"] > td:nth-of-type(2): "$1.15" (money, shown 3 times, 0 read); in the JSON at GET /api/cart $.items[0].unit_price
  • [data-testid="cart-row"] > td:nth-of-type(3): "3" (number, shown 3 times, 0 read); in the JSON at GET /api/cart $.items[0].quantity
  • [data-testid="cart-row"] > td:nth-of-type(4): "$3.45" (money, shown 3 times, 0 read)

/customers/4: 1 of 5 values (20%)

Not covered:

  • [data-testid="customer-name"]: "Dana Ruiz" (text); in the JSON at GET /api/customers/4 $.name
  • main > p: "dana.ruiz@example.com · joined Jul 23, 2025" (date)
  • [data-testid="customer-order-row"] > td:nth-of-type(2): "Apr 15, 2026" (date, shown 3 times, 0 read)
  • [data-testid="customer-order-row"] > td:nth-of-type(4): "$55.00" (money, shown 3 times, 0 read); in the JSON at GET /api/customers/4 $.orders[0].total_cents

/customers: 1 of 4 values (25%)

Not covered:

  • [data-testid="customer-row"] > td:nth-of-type(1) > a: "Yusuf Demir" (text, shown 10 times, 0 read); in the JSON at GET /api/customers?limit=10&offset=0 $.items[0].name
  • [data-testid="customer-row"] > td:nth-of-type(3): "Mar 28, 2026" (date, shown 10 times, 0 read)
  • [data-testid="pager"] > span:nth-of-type(2): "Page 1 of 3" (number)

/: 3 of 4 values (75%)

Not covered:

  • main > section > div:nth-of-type(3) > p:nth-of-type(2): "Ash Dining Chair, +20" (number); in the JSON at GET /api/restocks/latest $.quantity

/products?category=lighting: 1 of 3 values (33%)

Not covered:

  • [data-testid="product-name"]: "Paper Pendant Shade" (text, shown 5 times, 0 read); in the JSON at GET /api/products?category=lighting $.items[0].name
  • [data-testid="price"]: "$32.00" (money, shown 5 times, 0 read); in the JSON at GET /api/products?category=lighting $.items[0].price_cents

/orders/1001: 3 of 10 values (30%)

Not covered:

  • main > h1: "Order #1001" (number); in the JSON at GET /api/orders/1001 $.id
  • main > dl:nth-of-type(1) > dd:nth-of-type(1) > a: "Dana Ruiz" (text); in the JSON at GET /api/orders/1001 $.customer.name
  • main > table > tbody > tr > td:nth-of-type(1) > a: "Walnut Desk Lamp" (text, shown 2 times, 0 read); in the JSON at GET /api/orders/1001 $.items[0].name
  • main > table > tbody > tr > td:nth-of-type(2): "1" (number, shown 2 times, 0 read); in the JSON at GET /api/orders/1001 $.items[0].quantity
  • main > table > tbody > tr > td:nth-of-type(3): "$24.99" (money, shown 2 times, 0 read); in the JSON at GET /api/orders/1001 $.items[0].unit_price_cents
  • [data-testid="subtotal"]: "$27.29" (money); in the JSON at GET /api/orders/1001 $.subtotal_cents
  • [data-testid="shipping"]: "$5.00" (money); in the JSON at GET /api/orders/1001 $.shipping_cents

/orders/1002: 2 of 12 values (17%)

Not covered:

  • main > h1: "Order #1002" (number); in the JSON at GET /api/orders/1002 $.id
  • main > dl:nth-of-type(1) > dd:nth-of-type(1) > a: "Ivan Petrov" (text); in the JSON at GET /api/orders/1002 $.customer.name
  • [data-testid="placed-at"]: "Mar 20, 2026, 3:05 PM" (date)
  • [data-testid="delivery-date"]: "March 23, 2026" (date)
  • main > table > tbody > tr > td:nth-of-type(1) > a: "Wool Throw Blanket" (text); in the JSON at GET /api/orders/1002 $.items[0].name
  • main > table > tbody > tr > td:nth-of-type(2): "1" (number); in the JSON at GET /api/orders/1002 $.items[0].quantity
  • main > table > tbody > tr > td:nth-of-type(3): "$79.00" (money); in the JSON at GET /api/orders/1002 $.items[0].unit_price_cents
  • [data-testid="subtotal"]: "$79.00" (money); in the JSON at GET /api/orders/1002 $.items[0].unit_price_cents
  • [data-testid="shipping"]: "$0.00" (money)
  • [data-testid="order-total"] > strong: "$66.50" (money); in the JSON at GET /api/orders/1002 $.total_cents

/orders/1003: 1 of 10 values (10%)

Not covered:

  • main > h1: "Order #1003" (number); in the JSON at GET /api/orders/1003 $.id
  • main > dl:nth-of-type(1) > dd:nth-of-type(1) > a: "Dana Ruiz" (text); in the JSON at GET /api/orders/1003 $.customer.name
  • [data-testid="placed-at"]: "Mar 27, 2026, 1:45 PM" (date)
  • main > table > tbody > tr > td:nth-of-type(1) > a: "Ceramic Bud Vase" (text); in the JSON at GET /api/orders/1003 $.items[0].name
  • main > table > tbody > tr > td:nth-of-type(2): "1" (number); in the JSON at GET /api/orders/1003 $.items[0].quantity
  • main > table > tbody > tr > td:nth-of-type(3): "$18.50" (money); in the JSON at GET /api/orders/1003 $.items[0].unit_price_cents
  • [data-testid="subtotal"]: "$18.50" (money); in the JSON at GET /api/orders/1003 $.items[0].unit_price_cents
  • [data-testid="shipping"]: "$5.00" (money); in the JSON at GET /api/orders/1003 $.shipping_cents
  • [data-testid="order-total"] > strong: "$23.50" (money); in the JSON at GET /api/orders/1003 $.total_cents

/orders: 1 of 4 values (25%)

Not covered:

  • [data-testid="order-row"] > td:nth-of-type(2): "Lena Fischer" (text, shown 12 times, 0 read); in the JSON at GET /api/orders $.items[8].customer_name
  • [data-testid="order-row"] > td:nth-of-type(3): "Mar 29, 2026" (date, shown 12 times, 0 read)
  • [data-testid="order-row"] > td:nth-of-type(5): "$52.90" (money, shown 12 times, 0 read); in the JSON at GET /api/orders $.items[8].total_cents

/products/12: 2 of 8 values (25%)

Not covered:

  • main > p > a: "Kitchen" (text); in the JSON at GET /api/products/12 $.category_name
  • [data-testid="product-name"]: "Stoneware Pour-Over Set" (text); in the JSON at GET /api/products/12 $.name
  • [data-testid="price"]: "$36.00" (money); in the JSON at GET /api/products/12 $.price_cents
  • main > dl > dd:nth-of-type(3): "/ 5" (number)
  • main > dl > dd:nth-of-type(3) > span:nth-of-type(2): "(0 reviews)" (number)
  • [data-testid="stock"]: "14 in stock" (number); in the JSON at GET /api/products/12 $.stock

/products/7: 3 of 9 values (33%)

Not covered:

  • main > p > a: "Lighting" (text); in the JSON at GET /api/products/7 $.category_name
  • [data-testid="brand"]: "Lumen Works" (text); in the JSON at GET /api/products/7 $.brand
  • main > dl > dd:nth-of-type(4): "/ 5" (number)
  • [data-testid="rating"]: "4.3" (number); in the JSON at GET /api/products/7 $.avg_rating
  • main > dl > dd:nth-of-type(4) > span:nth-of-type(2): "(17 reviews)" (number)
  • [data-testid="stock"]: "12 in stock" (number); in the JSON at GET /api/products/7 $.stock

/products: 1 of 3 values (33%)

Not covered:

  • [data-testid="result-count"]: "23 results" (number); in the JSON at GET /api/products $.total
  • [data-testid="price"]: "$1.15" (money, shown 20 times, 0 read); in the JSON at GET /api/products $.items[0].price_cents

API fields

  • GET /api/cart: 1 of 5 fields (20%). Not covered: $.items[*].product_id, $.items[*].name, $.items[*].unit_price, $.items[*].quantity
  • GET /api/customers: 2 of 7 fields (29%). Not covered: $.items[*].id, $.items[*].name, $.items[*].created_at, $.limit, $.offset
  • GET /api/customers/4: 1 of 8 fields (13%). Not covered: $.id, $.name, $.email, $.created_at, $.orders[*].status, $.orders[*].placed_at, $.orders[*].total_cents
  • GET /api/orders: 1 of 5 fields (20%). Not covered: $.items[*].customer_name, $.items[*].status, $.items[*].placed_at, $.items[*].total_cents
  • GET /api/orders/1001: 3 of 14 fields (21%). Not covered: $.id, $.status, $.customer.id, $.customer.name, $.items[*].product_id, $.items[*].name, $.items[*].quantity, $.items[*].unit_price_cents, $.subtotal_cents, $.discount_cents, $.shipping_cents
  • GET /api/orders/1002: 2 of 14 fields (14%). Not covered: $.id, $.placed_at, $.delivery_date, $.customer.id, $.customer.name, $.items[*].product_id, $.items[*].name, $.items[*].quantity, $.items[*].unit_price_cents, $.subtotal_cents, $.shipping_cents, $.total_cents
  • GET /api/orders/1003: 1 of 14 fields (7%). Not covered: $.id, $.placed_at, $.delivery_date, $.customer.id, $.customer.name, $.items[*].product_id, $.items[*].name, $.items[*].quantity, $.items[*].unit_price_cents, $.subtotal_cents, $.discount_cents, $.shipping_cents, $.total_cents
  • GET /api/products: 2 of 8 fields (25%). Not covered: $.items[*].id, $.items[*].category, $.items[*].price_cents, $.items[*].sale_price_cents, $.limit, $.offset
  • GET /api/products/12: 2 of 10 fields (20%). Not covered: $.id, $.name, $.category, $.category_name, $.price_cents, $.sale_price_cents, $.stock, $.review_count
  • GET /api/products/7: 3 of 10 fields (30%). Not covered: $.id, $.category, $.category_name, $.brand, $.stock, $.avg_rating, $.review_count
  • GET /api/products/count: 0 of 1 fields (0%). Not covered: $.total
  • GET /api/restocks/latest: 1 of 4 fields (25%). Not covered: $.product_id, $.product_name, $.quantity
  • GET /api/stats: 2 of 2 fields (100%)

Report: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/report.md (and run.json)

12 kinds of bugs it recognises

These bugs are easy to miss, because each part of the app looks fine on its own. Every problem Samethru finds is matched to one of these kinds, with a plain explanation. Each example below is real Samethru output: what the page showed, and what it should have shown.

Money and numbers

Sale price, /products/7

$1,999.00

Should be: $19.99, saved as 1999 cents

Cents vs dollars

cents-vs-dollars

A price saved in cents is shown as if it were dollars, so it’s 100 times too big (or the other way round).

Cart total, /cart

$35.79

Should be: $35.80

Rounding error

rounding-error

A total is off by a cent because of the way the code adds up or rounds the numbers.

Discount, /orders/1002

$12.5

Should be: $12.50

Wrong decimal places

wrong-decimal-places

The amount is right, but it’s shown with the wrong number of decimals.

Old data

Low-stock products, the dashboard

4

Should be: 2. The database changed about 2.2 s after the copy was made, and the copy was never updated.

Stale cached value

stale-cache

A saved copy (a cache) keeps showing an old number after the real one changed.

Dates and times

Order time, /orders/1001

Mar 14, 2026, 6:30 PM

Should be: 2:30 PM New York time (18:30 UTC)

Timezone shift

timezone-shift

A time is off by whole hours because it was converted to the wrong time zone, or not converted.

Delivery date, /orders/1001

March 16, 2026

Should be: March 17, 2026

Date off by one day

date-off-by-one

A date shows one day early or late, because a time zone moved it across midnight.

Missing values

Brand, /products/12

null

Should be: Blank. No brand is saved.

null, undefined or NaN shown as text

null-as-text

A value that’s missing is shown as the word “null” (or “undefined”, “NaN”) instead of being left blank.

Rating, /products/12

0.0

Should be: No rating. There are no reviews yet.

Missing value shown as 0

missing-as-zero

A value that’s missing is shown as 0, which looks like a real number.

Lists and counts

Product list, /products

20 products

Should be: 23 products. The last 3 can’t be reached.

List cut off by pagination

pagination-cutoff

A list shows only the first page of results, and there’s no way to see the rest.

Lighting category, /products?category=lighting

23 results

Should be: 5 results

Wrong count after filtering

wrong-filtered-count

A count ignores the filter and shows the total of everything instead.

Orders, newest first, /orders

#1004#1003#1002#1001

Should be: #1012, #1011, #1010, #1009

Sort order differs between layers

sort-order-mismatch

A list has the right items, in the wrong order.

Labels

Order status, /orders/1003

Cancelled

Should be: Refunded

Wrong label for a status

wrong-enum-label

A status is shown with the wrong name.

Four possible results. Only PASS means it’s right.

Every check ends with one of these four. Samethru never says a value is fine unless the check actually ran and the values matched. If a check couldn’t run, or couldn’t decide, it says so.

  1. PASS

    $24.99

    It matches

    The check ran, and the value is the same where it’s saved, sent and shown.

    Example: the price is 2499 cents in the database and the API, and $24.99 on the page.

  2. FAIL

    $1,999.00

    It doesn’t match

    The check ran and found a difference. You get the value at each stop, where it came from, and the kind of bug.

    Example: the sale price is 1999 cents in the API but $1,999.00 on the page.

  3. NOT_RUN

    It couldn’t check

    Something stopped the check from running, such as the app not being started. It says why:

    couldn't load http://127.0.0.1:4321 (connection refused); is the app running?

  4. INCONCLUSIVE

    It couldn’t tell

    The check ran, but couldn’t find what it needed to decide. It says what was missing:

    Page: no element matches "[data-testid=list-price]"

So a check that didn’t run never slips through as a pass. When nothing failed but some checks didn’t run or couldn’t decide, the report says “Not every check passed: NOT_RUN and INCONCLUSIVE checks are not passes.” and samethru run exits with 2, so an automated build stops too.

It also tells you what it didn’t check

Checking 20 values doesn’t mean much if your pages show 77. Every report counts how many values on each page were checked, and lists every one that wasn’t, so nothing is quietly assumed to be fine.

20 of 77values checked across 12 pages (26%)

21 of 102pieces of data sent by the server checked, across 13 API endpoints (21%)

checked not checked

  • /3 of 4
  • /cart1 of 5
  • /customers1 of 4
  • /customers/41 of 5
  • /orders1 of 4
  • /orders/10013 of 10
  • /orders/10022 of 12
  • /orders/10031 of 10
  • /products1 of 3
  • /products?category=lighting1 of 3
  • /products/122 of 8
  • /products/73 of 9

On the example page at the top, these 6 values weren’t checked, and the report lists them by name:

  • “Lighting”
  • “Lumen Works”
  • “/ 5”
  • “4.3”
  • “(17 reviews)”
  • “12 in stock”

You can also set a minimum. With --min-coverage 60, the run fails when less than 60% of the values on your pages are checked.

Get started

You need an app you can run on your computer, Node.js 22.13 or later, and an AI coding tool: Claude Code, Cursor, Codex or Gemini CLI. Four steps:

  1. Set it up

    npx samethru init

    Run this in your project’s folder. It finds the AI tools on your computer and connects Samethru to the ones you choose. It shows every change before making it and asks first. (--dry-run shows the changes without making any.)

    npx samethru init
    AI tools on this machine:
      ✓ Claude Code (~/.claude, ~/.claude.json)
      · Cursor: not found
      ✓ Codex (codex on PATH, ~/.codex)
      · Gemini CLI: not found
    
    ── MCP server and .gitignore ───────────────────────────────────
    • Add the samethru server to Claude Code (this project): .mcp.json
    • Add the samethru server to Codex (this project): .codex/config.toml
    • Keep .samethru/runs/ and .samethru/auth.json out of git: .gitignore
    
      ✓ Wrote .mcp.json
      ✓ Wrote .codex/config.toml
      ✓ Wrote .gitignore
    Cut from the output: [ cut: the config, the audit skill, AGENTS.md and the browser check ]
    Next: start your app, then ask your assistant to "run a Samethru audit". Check the setup any time with `npx samethru doctor`.
    

    Then tell it where your app runs and where its database is, in .samethru/config.yaml. Samethru only ever reads your database, never changes it, and passwords stay in environment variables:

    version: 1
    baseUrl: http://localhost:3000
    browser:
      timezone: America/New_York
    databases:
      main:
        driver: postgres            # or sqlite, with path: db/development.sqlite3
        url: ${env:DATABASE_URL}
  2. Check that it’s ready

    npx samethru doctor

    Start your app the way you normally do, then run this. It tries every part, from the browser to the database connection, and tells you what to fix. If your pages need a login, run npx samethru login first and log in as usual.

    npx samethru doctor
    Cut from the output: [ cut: the project folder ]
    ✓ Node.js: 23.11.0
    ✓ Project: .samethru/config.yaml is valid; 20 saved checks
    ✓ Claude Code: uses Samethru (.mcp.json)
    ✓ Codex: uses Samethru (.codex/config.toml); Codex reads it only in trusted projects
    ✓ MCP server: starts; offers 7 tools and the audit prompt
    ✓ Browser: chrome 154.0.8037.97 (headless)
    ✓ Database main (sqlite): connected; read-only verified (runtime self-test: a readOnly connection to a scratch database refused CREATE TABLE; opened with readOnly: true; PRAGMA query_only = ON, read back as 1)
    ✓ App: http://127.0.0.1:4321 answered (200)
    
    Everything looks good.
    
  3. Ask your AI assistant

    “Run a Samethru audit.”

    Type that into your AI tool, or ask it to check one page. Your assistant reads your code to find where each value on your pages comes from, and writes a small check for each one. Samethru then runs the checks against your app and reports what it found. (In the Claude Code CLI you can also type /mcp__samethru__audit, and in Gemini CLI /audit.)

  4. Run the checks again, any time

    npx samethru run --min-coverage 60

    The checks are saved as small files in your project (.samethru/checks/), so you can run them again without AI, for example every time you push code to CI. Start your app and a test database first. The exit code says how it went:

    Exit codes of samethru run
    Exit codeMeaning
    0Every check ran and passed.
    1At least one check found a wrong value.
    2Nothing was wrong, but something didn’t run or couldn’t decide, no check ran at all, or too few values were checked (below --min-coverage).
    3Something is wrong with the setup or the command.

Safe to run on your app

Samethru runs on your own computer. It only reads your data; it never changes it.

It only reads

  • Your database is opened read-only, in more than one way. Samethru only runs single SELECT queries, checks that the connection really is read-only, and doctor warns you if the database user could make changes.
  • It only reads from your server too: GET and HEAD requests, unless you allow others in the config. GraphQL: queries only, never mutations.
  • It only talks to your own computer (localhost) unless you add other addresses to allowedHosts.
  • Your saved login stays private. Only you can read the file (.samethru/auth.json), it’s kept out of git, and it’s never shown to your assistant.

It doesn’t send your data anywhere

Samethru makes no network calls of its own: no tracking, no update checks, and no calls to AI models. It only talks to the app and database you point it at.

One thing to know about your AI tool:

What it returns to your coding assistant (query rows, page text, short response previews) is handled like any other tool output. Most assistants send tool output to their model provider, so treat it the way you treat your assistant reading your files. To keep that small, Samethru returns 20 rows by default and keeps previews short. Full responses, screenshots and evidence are written to .samethru/runs/ on your disk, and only their paths are returned. Secrets (environment-variable values, the saved login’s cookies) are removed from everything it outputs.

Requirements

Node.js
22.13 or later
Browser
Google Chrome, or Playwright’s Chromium
Database
PostgreSQL or SQLite. MySQL is planned.
System
macOS or Linux. Windows should work but isn’t tested yet.
AI tool
Claude Code, Cursor, Codex or Gemini CLI

For now, Samethru can only check values it can reach by opening a page’s address. It can’t click buttons, switch tabs, fill in forms or press “load more” yet.