stardeckai/lgtm

Prove that your tests actually test something. Powered by Jev and your own TypeSafe API key.

TypeScript

1

26 commits

updated Sep 20, 2026

See the code

See what people are saying (1)

README

😐👍...lgtm?

npm ci license

Prove that your tests actually test something. Powered by Jev and your own TypeSafe API key.

lgtm findings: file:line, the check, its probability and a one-line reason

Your agent wrote 40 tests. They're all green. What do they prove? lgtm reads every test block with its implementation and tells you which ones are useless.

It runs on Jev by TypeSafe, with your own TYPESAFE_API_KEY.

With this, you can prove that your agent actually wrote code that actually works, so you can say it lgtm 😐👍.

Evaluated and hill-climbed on real live apps built by Stardeck: 15,000+ real test blocks scored, 262 each read against its implementation and labelled, every check's threshold fitted so that when lgtm points at a test, the test is worth your time.

What you get

  • the lgtm CLI: run it on a file, a directory or --diff, in your terminal or in CI
  • a /lgtm skill for your coding agents, so the agent that wrote the tests runs the audit and fixes what it finds
  • an /actually-test skill: write the tests the change needs, prove them red, then iterate with lgtm until they pass

Install

bun add -g @stardeckai/lgtm
pnpm add -g @stardeckai/lgtm
npm i -g @stardeckai/lgtm
# or per project
pnpm add -D @stardeckai/lgtm   # then: pnpm lgtm ...   (npm: npx lgtm ...)

If lgtm is not found after a global install, the package manager's global bin directory is not on your PATH. Run npm prefix -g (or pnpm bin -g, bun pm bin -g) and add its bin to PATH, then open a new shell or run rehash in zsh.

Setup

lgtm init                    # paste your API key, then install the /lgtm and /actually-test skills

It asks for your TypeSafe API key (get one at https://typesafe.ai), then asks whether to install the /lgtm and /actually-test skills with npx skills.

The key lands in ~/.config/lgtm/config.json (mode 0600); TYPESAFE_API_KEY in the environment wins over it.

Skip the prompts with --skill <where>:

--skill
globalevery project, via the skills CLI (what --yes picks)
projectthis project only, via the skills CLI
claudewrite ~/.claude/skills/{lgtm,actually-test}/SKILL.md directly, no npx
noneskip them

If the skills CLI can't run, init falls back to writing the Claude Code skills itself.

Running lgtm before setup exits with 😐✋ No API key. Run: lgtm init.

Use

lgtm .                     # every *.test.* / *.spec.* file under a directory (a path is required)
lgtm src/user.test.ts      # one file, or a directory
lgtm --diff origin/main    # only tests changed vs a base, with the diff as evidence
lgtm --diff                # just what you're working on: changed and new tests, plus tests of changed code, vs the default branch
lgtm --dry-run src         # only the plan: files, estimated cost and runtime; no key needed

Every run starts with that plan and asks Run? [Y/n]. Outside a terminal (CI, an agent) it stops after the plan unless you pass --yes.

lgtm plan: files, estimated cost and runtime, then a confirmation prompt
lgtm . --yes --format json # non-interactive

For a one-off run without installing: npx @stardeckai/lgtm --dry-run src.

test/payment.test.ts:42  "rejects expired cards"
  😐👏 mocks-seam-under-test 0.93 — The collaborator that decides this behaviour is a mock, so the test only proves the mock works.

test/refund.test.ts:17  "refunds a captured charge"
  😐🤏 swallowed-error-as-success 0.88 — The test stays green whether the error is caught, logged, or never thrown; it never pins the specific failure.

😐🫵  2 tests prove nothing.
4 contract-integration · 19 mocked-seam · 8 pure-logic

98120 input tokens used ≈ $0.0041 ($0.0001 per test)
9.8s (0.3s per test)

Four faces, one per family: 😐🤏 the assertion proves this much, 😐👏 you tested the mock, 😐🤌 what exactly are we doing here, 😐🫸 do not merge this. Colour is severity (red at 0.9 and above, yellow at or over the check's threshold, dim for --verbose suspects). The verdict is one line, 😐👍 N tests. fine. allegedly. or 😐🫵 N tests prove nothing. --format github and --format json stay plain.

/actually-test

Gets your agent to actually test the code it just wrote, and iterates on /lgtm (don't worry, it's cached) until it proves the code is actually tested.

/actually-test: the agent runs the CLI, writes src/cli.test.ts, then proves the tests red

Flags

flag
--diff [base]only the tests your change touches, with the diff in the state. Without a base it uses the repo's default branch (origin/HEAD, else origin/main, else main, else master), and always compares against git merge-base <base> HEAD, so a branch that is behind does not report the base's own commits. Counts uncommitted and untracked files, keeps only the test blocks that overlap a changed line, and adds any test whose imports include a changed source file (all of its blocks). The two diff checks only run on blocks the diff touched
--diff-all-blockswith --diff, audit every block of a changed file instead of only the changed ones
--threshold <0..1>override every check's threshold
--only <ids,…> / --skip <ids,…>pick checks
--format text|github|jsongithub emits ::warning annotations
--concurrency <n>parallel requests, default 4
--no-impldon't send implementation source
--ignore <pattern>skip paths; repeatable. Also reads .lgtmignore in the cwd, one gitignore-style pattern per line (evals/, **/fixtures/**, *.stories.test.ts)
--leansend ~2.5x fewer tokens (8k of implementation, no test file or guidelines). Thresholds are calibrated on full context, so expect several times more false positives; only for rate limits or enormous test files
--no-cacheignore the answer cache
--failexit 1 when there are findings
--fail-on-errorexit 1 when a block was skipped by an API error
--classeslist every test with its class before the findings
--verbosealso show 0.5-to-threshold findings
--list-checksprint the checks

Why

Coding agents are prolific test writers and terrible test critics. They mock whatever is inconvenient, assert that the mock was called, compute the expected value with the code under test, and hand you a suite where every line is covered and nothing is verified. A test that checks a trace's name. A test for the thing you decided not to build. Nobody reads those files. The PR says "added tests" and gets merged.

The bug that pages you lives in a seam. One side writes, the other reads, and every unit test mocked at least one of them to agree. No linter catches that. It's a judgment call, and judgment used to cost a senior engineer's afternoon per PR.

Reads like a review, runs like a linter. Every finding is one test, one smell, one probability, one sentence you can act on. 😐👏 mocks-seam-under-test means you tested the mock. 😐👏 reimplements-logic means the test and the implementation share the same bug. The summary tells you how much of your suite actually crosses a seam.

Opinionated by design. Few wide tests with real collaborators beat a hundred mocked units. Delete with confidence: a good audit shrinks the suite. And when the suite is clean, it says so.

lgtm clean run: 36 tests. fine. allegedly.

Now it costs a cent. Jev bills $0.042 per million input tokens and answers in under a second. lgtm shows you the bill and the runtime before it spends, and caches every answer. Every check holds precision 1.00 on the held-out slice of the corpus (evals/).

What lgtm likes

A test that crosses a seam with both sides real and asserts that they agree. That test fails when the wiring breaks, which is how most things actually break.

It dislikes tests of one-line helpers (any real test of the feature covers them for free), tests that mock everything except the function name, and one-off assertions that would survive the feature being deleted. So the summary prints what your suite is made of: N contract-integration · N mocked-seam · N pure-logic. --classes lists every test with its class, which is the number to watch during an audit. Retiring three unit tests for one wider test that really fails is a win, not a coverage loss.

Checks

--format json adds a checks map with a longer explanation and the fix, once per check.

check
😐⁠🤏would-pass-if-brokenBreak the behaviour the name describes and this test still passes; the fixture never reaches it.
😐⁠🤏vacuous-assertionThe assertion accepts almost any output, so it cannot fail for a real bug.
😐⁠🤏assertion-weaker-than-nameThe name promises a behaviour the assertions never check.
😐⁠👏reimplements-logicThe expected value is computed with the same logic as production, so both can be wrong together.
😐⁠👏mocks-seam-under-testThe collaborator that decides this behaviour is a mock, so the test only proves the mock works.
😐⁠👏mock-mirrors-implementationThe mock re-encodes the production logic; any implementation that agrees with the copy passes.
😐⁠🤏tests-calls-not-outcomesIt asserts that a function was called, not what happened as a result.
😐⁠🤌tests-internalsIt asserts private state, class names or call order instead of observable behaviour; a refactor breaks it, a bug does not.
😐⁠🤌setup-dominatesMost of the setup never reaches the assertion. It is scenery.
😐⁠🤏broad-snapshotThe snapshot pins everything and explains nothing, so it will be re-recorded on the next change.
😐⁠🤏swallowed-error-as-successThe test stays green whether the error is caught, logged, or never thrown; it never pins the specific failure.
😐⁠🤌impossible-fixtureThe fixture builds a state production validation could never produce.
😐⁠🤌happy-path-only-of-risky-boundaryThe refusal path this code exists for, the one that pages you, has no test here or among its siblings.
😐⁠🤌trivial-primitiveA one-line helper tested in isolation; any real test of the feature that uses it would catch the same break.
😐⁠👏over-mockedSo many collaborators are faked that only glue is left to fail.
😐⁠🫸regression-does-not-distinguishThis regression test also passes on the buggy code, so it does not lock the fix. (needs --diff)
😐⁠🫸changed-in-lockstepImplementation and expected values changed together, so the test may only mirror the new behaviour. (needs --diff)

Evals

The corpus is 521 public + 262 private labelled test cases, synthetic and anonymized real-world, with positives, hard negatives and genuinely good tests. The ground truth is kept in expect.json so it never reaches the model.

The 262 private cases come from real Stardeck customer apps and from Stardeck's own codebase, each read against its implementation, labelled, and anonymized. They are scored in these numbers but not published, because anonymization removes names, not shape. The 521 public cases in evals/cases reproduce with pnpm eval alone.

Scores are at each check's own threshold. 165 of the cases are holdout, never used to fit a threshold or a prompt.

checkcasesthresholdprecision (holdout)recall (holdout)precision (all)recall (all)
would-pass-if-broken990.601.000.360.960.47
vacuous-assertion1790.701.000.631.000.69
assertion-weaker-than-name820.601.001.000.980.95
reimplements-logic2060.851.000.501.000.67
mocks-seam-under-test1210.801.000.331.000.23
mock-mirrors-implementation360.601.000.501.000.83
tests-calls-not-outcomes740.651.001.001.000.87
tests-internals640.651.000.801.000.70
setup-dominates600.601.001.001.000.94
broad-snapshot510.351.001.001.000.94
swallowed-error-as-success760.801.000.801.000.46
impossible-fixture630.551.001.001.000.75
happy-path-only-of-risky-boundary880.701.000.501.000.50
trivial-primitive1220.851.000.711.000.66
over-mocked840.551.000.201.000.38
regression-does-not-distinguish410.351.000.751.000.82
changed-in-lockstep360.751.001.001.000.94

Test class accuracy: 665/783 (0.85) on all cases, 145/165 (0.88) on holdout.

A full cold run of the corpus is about 3,988,609 input tokens ≈ $0.1675 (estimated from the states; the last run spent $0.0301 after cache hits).

Every miss and false positive is listed in evals/RESULTS.md. The public cases reproduce with pnpm eval.

CI

- uses: actions/cache@v4
  with:
    path: node_modules/.cache/lgtm
    key: lgtm-${{ hashFiles('**/pnpm-lock.yaml', '**/package-lock.json') }}
    restore-keys: lgtm-
- run: npx @stardeckai/lgtm --diff origin/${{ github.base_ref }} --yes --format github
  env:
    TYPESAFE_API_KEY: ${{ secrets.TYPESAFE_API_KEY }}

The cache step is optional: --diff already limits a PR run to the tests it touched, and answers are keyed by the full state (test, imports, implementation, guidelines) plus the check wording and lgtm version, so a hit means nothing relevant changed. Do not commit the cache directory; it churns on every refactor and never shrinks.

lgtm is advisory by default: it prints findings and exits 0. Fetch enough history for the base ref (fetch-depth: 0) and add --fail once the findings are clean enough that you want them blocking.

Cost

TypeSafe bills $0.042 per million input tokens and nothing for output, so lgtm is cheap enough to run on every PR. One request per test block. Each state is trimmed to at most 100,000 chars (~25,000 tokens, under TypeSafe's 32k state limit): the test code, the whole test file with the block fenced, the file's imports and sibling test names, up to 60,000 chars of the imported implementation (one hop deep) and the test sections of any CLAUDE.md/AGENTS.md above it. --lean cuts that back to an 8,000-char implementation and no test file or guidelines.

runblocksinput tokenscost
a PR touching 20 tests (--diff)20~100k~$0.004
a mid-sized suite500~2.5M~$0.11
a large monorepo suite5,000~25M~$1.05
the eval corpus (pnpm eval, cold)768see the Evals section

Answers are cached by state hash under node_modules/.cache/lgtm, so a re-run after editing one test only pays for that test. The implementation source is the main cost lever: --no-impl cuts the bulk of each request at the price of weaker would-pass-if-broken and reimplements-logic answers. Every run prints its input tokens and the estimated cost.

Config

The key lives in ~/.config/lgtm/config.json. TYPESAFE_API_KEY in the environment always wins over it.

lgtm key <new-key>           # swap the saved key; `lgtm key` alone prompts
lgtm usage                   # cost so far: all time, last day, last week, this worktree
lgtm clear-cache             # drop this project's cached answers (node_modules/.cache/lgtm)
lgtm skill                   # (re)install the /lgtm and /actually-test skills, e.g. to add another agent
lgtm init                    # both steps again

Thresholds

Each check has its own threshold, fitted on the eval corpus to the lowest probability that still gives zero false positives (the threshold column above). That trades recall for precision on purpose: a finding should be worth your time. To see what sits just under the line, run --verbose, or --threshold 0.5 --format json on a suite you know well and pick your own number. A check that is consistently wrong for your codebase belongs in --skip.

Development

This repo uses pnpm.

Each check is one file under src/checks/<category>/; add a new one there and register it in src/checks/index.ts.

pnpm install
pnpm typecheck
pnpm test
pnpm build
node dist/cli.js --dry-run src   # no API key needed
npm link                         # expose this checkout as the global `lgtm` (symlink to dist/cli.js; rebuild to update)
pnpm gen:skill                   # regenerate skills/*/SKILL.md after changing a check or a skill (a test guards drift)
pnpm eval                        # run the labelled corpus against Jev; --offline re-scores, --fit-thresholds refits

License

MIT

Contributors

stardeckai/lgtm

Prove that your tests actually test something. Powered by Jev and your own TypeSafe API key.

TypeScript

1

26 commits

updated Sep 20, 2026

See the code

See what people are saying (1)

README

😐👍...lgtm?

npm ci license

Prove that your tests actually test something. Powered by Jev and your own TypeSafe API key.

lgtm findings: file:line, the check, its probability and a one-line reason

Your agent wrote 40 tests. They're all green. What do they prove? lgtm reads every test block with its implementation and tells you which ones are useless.

It runs on Jev by TypeSafe, with your own TYPESAFE_API_KEY.

With this, you can prove that your agent actually wrote code that actually works, so you can say it lgtm 😐👍.

Evaluated and hill-climbed on real live apps built by Stardeck: 15,000+ real test blocks scored, 262 each read against its implementation and labelled, every check's threshold fitted so that when lgtm points at a test, the test is worth your time.

What you get

  • the lgtm CLI: run it on a file, a directory or --diff, in your terminal or in CI
  • a /lgtm skill for your coding agents, so the agent that wrote the tests runs the audit and fixes what it finds
  • an /actually-test skill: write the tests the change needs, prove them red, then iterate with lgtm until they pass

Install

bun add -g @stardeckai/lgtm
pnpm add -g @stardeckai/lgtm
npm i -g @stardeckai/lgtm
# or per project
pnpm add -D @stardeckai/lgtm   # then: pnpm lgtm ...   (npm: npx lgtm ...)

If lgtm is not found after a global install, the package manager's global bin directory is not on your PATH. Run npm prefix -g (or pnpm bin -g, bun pm bin -g) and add its bin to PATH, then open a new shell or run rehash in zsh.

Setup

lgtm init                    # paste your API key, then install the /lgtm and /actually-test skills

It asks for your TypeSafe API key (get one at https://typesafe.ai), then asks whether to install the /lgtm and /actually-test skills with npx skills.

The key lands in ~/.config/lgtm/config.json (mode 0600); TYPESAFE_API_KEY in the environment wins over it.

Skip the prompts with --skill <where>:

--skill
globalevery project, via the skills CLI (what --yes picks)
projectthis project only, via the skills CLI
claudewrite ~/.claude/skills/{lgtm,actually-test}/SKILL.md directly, no npx
noneskip them

If the skills CLI can't run, init falls back to writing the Claude Code skills itself.

Running lgtm before setup exits with 😐✋ No API key. Run: lgtm init.

Use

lgtm .                     # every *.test.* / *.spec.* file under a directory (a path is required)
lgtm src/user.test.ts      # one file, or a directory
lgtm --diff origin/main    # only tests changed vs a base, with the diff as evidence
lgtm --diff                # just what you're working on: changed and new tests, plus tests of changed code, vs the default branch
lgtm --dry-run src         # only the plan: files, estimated cost and runtime; no key needed

Every run starts with that plan and asks Run? [Y/n]. Outside a terminal (CI, an agent) it stops after the plan unless you pass --yes.

lgtm plan: files, estimated cost and runtime, then a confirmation prompt
lgtm . --yes --format json # non-interactive

For a one-off run without installing: npx @stardeckai/lgtm --dry-run src.

test/payment.test.ts:42  "rejects expired cards"
  😐👏 mocks-seam-under-test 0.93 — The collaborator that decides this behaviour is a mock, so the test only proves the mock works.

test/refund.test.ts:17  "refunds a captured charge"
  😐🤏 swallowed-error-as-success 0.88 — The test stays green whether the error is caught, logged, or never thrown; it never pins the specific failure.

😐🫵  2 tests prove nothing.
4 contract-integration · 19 mocked-seam · 8 pure-logic

98120 input tokens used ≈ $0.0041 ($0.0001 per test)
9.8s (0.3s per test)

Four faces, one per family: 😐🤏 the assertion proves this much, 😐👏 you tested the mock, 😐🤌 what exactly are we doing here, 😐🫸 do not merge this. Colour is severity (red at 0.9 and above, yellow at or over the check's threshold, dim for --verbose suspects). The verdict is one line, 😐👍 N tests. fine. allegedly. or 😐🫵 N tests prove nothing. --format github and --format json stay plain.

/actually-test

Gets your agent to actually test the code it just wrote, and iterates on /lgtm (don't worry, it's cached) until it proves the code is actually tested.

/actually-test: the agent runs the CLI, writes src/cli.test.ts, then proves the tests red

Flags

flag
--diff [base]only the tests your change touches, with the diff in the state. Without a base it uses the repo's default branch (origin/HEAD, else origin/main, else main, else master), and always compares against git merge-base <base> HEAD, so a branch that is behind does not report the base's own commits. Counts uncommitted and untracked files, keeps only the test blocks that overlap a changed line, and adds any test whose imports include a changed source file (all of its blocks). The two diff checks only run on blocks the diff touched
--diff-all-blockswith --diff, audit every block of a changed file instead of only the changed ones
--threshold <0..1>override every check's threshold
--only <ids,…> / --skip <ids,…>pick checks
--format text|github|jsongithub emits ::warning annotations
--concurrency <n>parallel requests, default 4
--no-impldon't send implementation source
--ignore <pattern>skip paths; repeatable. Also reads .lgtmignore in the cwd, one gitignore-style pattern per line (evals/, **/fixtures/**, *.stories.test.ts)
--leansend ~2.5x fewer tokens (8k of implementation, no test file or guidelines). Thresholds are calibrated on full context, so expect several times more false positives; only for rate limits or enormous test files
--no-cacheignore the answer cache
--failexit 1 when there are findings
--fail-on-errorexit 1 when a block was skipped by an API error
--classeslist every test with its class before the findings
--verbosealso show 0.5-to-threshold findings
--list-checksprint the checks

Why

Coding agents are prolific test writers and terrible test critics. They mock whatever is inconvenient, assert that the mock was called, compute the expected value with the code under test, and hand you a suite where every line is covered and nothing is verified. A test that checks a trace's name. A test for the thing you decided not to build. Nobody reads those files. The PR says "added tests" and gets merged.

The bug that pages you lives in a seam. One side writes, the other reads, and every unit test mocked at least one of them to agree. No linter catches that. It's a judgment call, and judgment used to cost a senior engineer's afternoon per PR.

Reads like a review, runs like a linter. Every finding is one test, one smell, one probability, one sentence you can act on. 😐👏 mocks-seam-under-test means you tested the mock. 😐👏 reimplements-logic means the test and the implementation share the same bug. The summary tells you how much of your suite actually crosses a seam.

Opinionated by design. Few wide tests with real collaborators beat a hundred mocked units. Delete with confidence: a good audit shrinks the suite. And when the suite is clean, it says so.

lgtm clean run: 36 tests. fine. allegedly.

Now it costs a cent. Jev bills $0.042 per million input tokens and answers in under a second. lgtm shows you the bill and the runtime before it spends, and caches every answer. Every check holds precision 1.00 on the held-out slice of the corpus (evals/).

What lgtm likes

A test that crosses a seam with both sides real and asserts that they agree. That test fails when the wiring breaks, which is how most things actually break.

It dislikes tests of one-line helpers (any real test of the feature covers them for free), tests that mock everything except the function name, and one-off assertions that would survive the feature being deleted. So the summary prints what your suite is made of: N contract-integration · N mocked-seam · N pure-logic. --classes lists every test with its class, which is the number to watch during an audit. Retiring three unit tests for one wider test that really fails is a win, not a coverage loss.

Checks

--format json adds a checks map with a longer explanation and the fix, once per check.

check
😐⁠🤏would-pass-if-brokenBreak the behaviour the name describes and this test still passes; the fixture never reaches it.
😐⁠🤏vacuous-assertionThe assertion accepts almost any output, so it cannot fail for a real bug.
😐⁠🤏assertion-weaker-than-nameThe name promises a behaviour the assertions never check.
😐⁠👏reimplements-logicThe expected value is computed with the same logic as production, so both can be wrong together.
😐⁠👏mocks-seam-under-testThe collaborator that decides this behaviour is a mock, so the test only proves the mock works.
😐⁠👏mock-mirrors-implementationThe mock re-encodes the production logic; any implementation that agrees with the copy passes.
😐⁠🤏tests-calls-not-outcomesIt asserts that a function was called, not what happened as a result.
😐⁠🤌tests-internalsIt asserts private state, class names or call order instead of observable behaviour; a refactor breaks it, a bug does not.
😐⁠🤌setup-dominatesMost of the setup never reaches the assertion. It is scenery.
😐⁠🤏broad-snapshotThe snapshot pins everything and explains nothing, so it will be re-recorded on the next change.
😐⁠🤏swallowed-error-as-successThe test stays green whether the error is caught, logged, or never thrown; it never pins the specific failure.
😐⁠🤌impossible-fixtureThe fixture builds a state production validation could never produce.
😐⁠🤌happy-path-only-of-risky-boundaryThe refusal path this code exists for, the one that pages you, has no test here or among its siblings.
😐⁠🤌trivial-primitiveA one-line helper tested in isolation; any real test of the feature that uses it would catch the same break.
😐⁠👏over-mockedSo many collaborators are faked that only glue is left to fail.
😐⁠🫸regression-does-not-distinguishThis regression test also passes on the buggy code, so it does not lock the fix. (needs --diff)
😐⁠🫸changed-in-lockstepImplementation and expected values changed together, so the test may only mirror the new behaviour. (needs --diff)

Evals

The corpus is 521 public + 262 private labelled test cases, synthetic and anonymized real-world, with positives, hard negatives and genuinely good tests. The ground truth is kept in expect.json so it never reaches the model.

The 262 private cases come from real Stardeck customer apps and from Stardeck's own codebase, each read against its implementation, labelled, and anonymized. They are scored in these numbers but not published, because anonymization removes names, not shape. The 521 public cases in evals/cases reproduce with pnpm eval alone.

Scores are at each check's own threshold. 165 of the cases are holdout, never used to fit a threshold or a prompt.

checkcasesthresholdprecision (holdout)recall (holdout)precision (all)recall (all)
would-pass-if-broken990.601.000.360.960.47
vacuous-assertion1790.701.000.631.000.69
assertion-weaker-than-name820.601.001.000.980.95
reimplements-logic2060.851.000.501.000.67
mocks-seam-under-test1210.801.000.331.000.23
mock-mirrors-implementation360.601.000.501.000.83
tests-calls-not-outcomes740.651.001.001.000.87
tests-internals640.651.000.801.000.70
setup-dominates600.601.001.001.000.94
broad-snapshot510.351.001.001.000.94
swallowed-error-as-success760.801.000.801.000.46
impossible-fixture630.551.001.001.000.75
happy-path-only-of-risky-boundary880.701.000.501.000.50
trivial-primitive1220.851.000.711.000.66
over-mocked840.551.000.201.000.38
regression-does-not-distinguish410.351.000.751.000.82
changed-in-lockstep360.751.001.001.000.94

Test class accuracy: 665/783 (0.85) on all cases, 145/165 (0.88) on holdout.

A full cold run of the corpus is about 3,988,609 input tokens ≈ $0.1675 (estimated from the states; the last run spent $0.0301 after cache hits).

Every miss and false positive is listed in evals/RESULTS.md. The public cases reproduce with pnpm eval.

CI

- uses: actions/cache@v4
  with:
    path: node_modules/.cache/lgtm
    key: lgtm-${{ hashFiles('**/pnpm-lock.yaml', '**/package-lock.json') }}
    restore-keys: lgtm-
- run: npx @stardeckai/lgtm --diff origin/${{ github.base_ref }} --yes --format github
  env:
    TYPESAFE_API_KEY: ${{ secrets.TYPESAFE_API_KEY }}

The cache step is optional: --diff already limits a PR run to the tests it touched, and answers are keyed by the full state (test, imports, implementation, guidelines) plus the check wording and lgtm version, so a hit means nothing relevant changed. Do not commit the cache directory; it churns on every refactor and never shrinks.

lgtm is advisory by default: it prints findings and exits 0. Fetch enough history for the base ref (fetch-depth: 0) and add --fail once the findings are clean enough that you want them blocking.

Cost

TypeSafe bills $0.042 per million input tokens and nothing for output, so lgtm is cheap enough to run on every PR. One request per test block. Each state is trimmed to at most 100,000 chars (~25,000 tokens, under TypeSafe's 32k state limit): the test code, the whole test file with the block fenced, the file's imports and sibling test names, up to 60,000 chars of the imported implementation (one hop deep) and the test sections of any CLAUDE.md/AGENTS.md above it. --lean cuts that back to an 8,000-char implementation and no test file or guidelines.

runblocksinput tokenscost
a PR touching 20 tests (--diff)20~100k~$0.004
a mid-sized suite500~2.5M~$0.11
a large monorepo suite5,000~25M~$1.05
the eval corpus (pnpm eval, cold)768see the Evals section

Answers are cached by state hash under node_modules/.cache/lgtm, so a re-run after editing one test only pays for that test. The implementation source is the main cost lever: --no-impl cuts the bulk of each request at the price of weaker would-pass-if-broken and reimplements-logic answers. Every run prints its input tokens and the estimated cost.

Config

The key lives in ~/.config/lgtm/config.json. TYPESAFE_API_KEY in the environment always wins over it.

lgtm key <new-key>           # swap the saved key; `lgtm key` alone prompts
lgtm usage                   # cost so far: all time, last day, last week, this worktree
lgtm clear-cache             # drop this project's cached answers (node_modules/.cache/lgtm)
lgtm skill                   # (re)install the /lgtm and /actually-test skills, e.g. to add another agent
lgtm init                    # both steps again

Thresholds

Each check has its own threshold, fitted on the eval corpus to the lowest probability that still gives zero false positives (the threshold column above). That trades recall for precision on purpose: a finding should be worth your time. To see what sits just under the line, run --verbose, or --threshold 0.5 --format json on a suite you know well and pick your own number. A check that is consistently wrong for your codebase belongs in --skip.

Development

This repo uses pnpm.

Each check is one file under src/checks/<category>/; add a new one there and register it in src/checks/index.ts.

pnpm install
pnpm typecheck
pnpm test
pnpm build
node dist/cli.js --dry-run src   # no API key needed
npm link                         # expose this checkout as the global `lgtm` (symlink to dist/cli.js; rebuild to update)
pnpm gen:skill                   # regenerate skills/*/SKILL.md after changing a check or a skill (a test guards drift)
pnpm eval                        # run the labelled corpus against Jev; --offline re-scores, --fit-thresholds refits

License

MIT

Contributors

Languages

TypeScript

100.0%