@artshllaku/gapix
v2.2.3
Published
Static test-gap and test-quality analysis for TypeScript — finds untested error paths, unasserted symbols, and broken tests in seconds
Maintainers
Readme
gapix
Your tests pass. But do they check anything?
test('adds', () => {
add(1, 2); // runs every line. Checks nothing. Passes forever.
});Coverage tools call that covered. It would still pass if add returned
garbage tomorrow.
gapix reads your source and your tests, works out which functions a test actually checks, and reports the ones it doesn't:
- error paths no test ever triggers
- functions your tests call but never check
- tests that pass no matter what the code does
No config. No test run. About a second.
Try it
In any TypeScript project:
npx @artshllaku/gapix checkOn a shopping cart with a single test:
assertion coverage 50% 1/2 symbols
defects 1 critical 0 warn 0 info
0 run, never checked · 1 never run · 1 test file
critical src/cart.ts:2 untested-throw-path
total throws 'empty cart' at line 2, but no test reaching it asserts that it throws.
→ expect(() => total(…)).toThrow('empty cart')
Biggest gaps
applyDiscount src/cart.ts:5Fix the first finding
total() throws when the cart is empty. A test calls total(), so line
coverage is satisfied. Nothing checks that the error ever fires.
Add the test gapix wrote for you:
test('rejects an empty cart', () => {
expect(() => total([])).toThrow('empty cart');
});Run it again and the critical is gone.
Find a gap, write the test, run again. That loop is the tool.
Install
npm install -D @artshllaku/gapixA dev dependency lands in node_modules/.bin, which is not on your shell's
PATH, so a bare gapix will not resolve. Use npx:
npx gapix checkOr run it from a script, where npm puts node_modules/.bin on the PATH for you:
// package.json
"scripts": {
"test:gaps": "gapix check"
}npm run test:gapsAvoid installing globally as well. With both installed, a bare
gapixresolves to the global copy, which may be older. You then getunknown commandfor flags your local version supports. Pick one. When in doubt,which gapixandgapix --version.
Use it in CI
Fail the build on anything critical:
gapix check --fail-on criticalgapix init --ci writes the config and a GitHub Actions workflow for you.
Adopting it on an existing codebase
Any established project has findings on day one. zod has 352. Turn on
--fail-on cold and the build goes red immediately, so you turn it back off.
Accept the backlog once, then fail only on what is new:
gapix check --update-baseline # writes .gapix/baseline.json
git add .gapix/baseline.jsongapix check --baseline .gapix/baseline.json --fail-on warnBaselined findings are still reported. They just never break the build. Anything outside the baseline does. Delete entries as you fix them.
The file is sorted and deduplicated, so it stays quiet in git. Entries match on a fingerprint that ignores line numbers, so a finding survives edits above it.
Scope a run to the pull request
gapix check --since mainReports what the branch touched instead of the whole backlog.
GitHub code scanning
SARIF puts findings in the Security tab and inline on the pull request. No bot, no custom action:
- run: npx gapix check --format sarif -o gapix.sarif.json
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: gapix.sarif.jsonGitHub tracks alerts across commits on the same fingerprint the baseline uses, so a finding is not raised again every time a line moves above it.
Exit codes
| exit | meaning |
|---|---|
| 0 | clean, or no gate configured |
| 1 | gate failed |
| 2 | usage error, such as an unknown flag |
| 3 | bad config |
| 4 | internal error |
These are stable. Pin against them.
Output formats
gapix check --format html # interactive report, opens in your browser
gapix check --format markdown # for a pull request comment
gapix check --format sarif # GitHub code scanning
gapix check --format json -o - | jq # pipe it anywhereHow it differs from coverage
Line coverage measures what ran. gapix measures what was checked.
Every exported function lands in one of four states. The gap between the first two is the entire point:
| state | meaning |
|---|---|
| asserted | a test checks a value that came from calling it |
| exercised | a test calls it, but no check traces back to the result |
| referenced | imported, never called |
| untested | no test reaches it |
Assertion coverage is the share of your public functions that are
asserted — how much of your code is verified, not how much of it executed.
Mocking a module demotes it to exercised. Driving a fake is not testing the
real thing.
Only your public API counts. Constructors, private methods and .d.ts files
are excluded, each with a recorded reason in the JSON report, so you can audit
the denominator yourself.
What it catches
gapix rules lists all of them. gapix explain <rule> gives the reasoning
behind one. The rules that earn their place:
| rule | what it catches |
|---|---|
| untested-throw-path | A function that throws, reached only by happy-path tests. Nothing else in the ecosystem checks this. |
| await-async-assertion | expect(locator).toBeVisible() with no await. The promise floats, so the test passes whatever the page does. |
| no-tests-without-assertions | A test with no checks. Critical when it never calls your code either, because then it cannot fail at all. A warning when it calls without checking, since a crash still surfaces. Checks inside page objects count, so Page Object suites are not flagged wrongly. |
| assertion-shape-mismatch | A function returns an object, but the only check is not.toBeNull(). Read from the declared return type, so terse-but-adequate checks are left alone. |
| no-focused-tests | A stray .only, silently disabling the rest of the file. |
| no-tautological-assertion | expect(1).toBe(1). Inflates the count, verifies nothing. |
| snapshot-only-test | A snapshot records what the code does today, not what it should do. |
Works with
Every row is backed by a fixture in tests/fixtures/. If it is listed, a test proves it.
| | checks | test structure |
|---|---|---|
| Jest, Vitest | expect().toBe(), .not, .resolves, expect.soft | .only / .skip / .each, xit / fit |
| Playwright | web-first matchers, expect.poll, await tracking | test.describe, fixtures via test.extend |
| Chai, Mocha | to.equal(), to.be.true, should | describe / context / suite |
| node:test | assert.*, assert.strict.* | test(), subtests |
| Type tests | expectTypeOf<T>(), assertType<T>(), @ts-expect-error, your own typed helpers | — |
| Cypress | cy...should(), .and() | describe / it |
| supertest | await request(app).get('/x').expect(200) | — |
Page objects and helpers are traced across files. If your spec calls
checkout.submit() and the check lives inside the page object, it counts —
including Playwright's fixture-based page objects.
Not supported: .vue, .svelte and .astro single-file components.
gapix runs against real repositories nightly, not just fixtures. zod, NestJS and tRPC are analysed every night, with parse failures and critical counts held to measured ceilings, so a new false positive fails the build.
Configuration
Optional. Drop a gapix.config.json anywhere above the directory you analyse:
{
"include": ["src/**/*.ts"],
"exclude": ["**/*.d.ts"],
"rules": { "no-skipped-tests": "off" },
"thresholds": { "failOn": "critical", "minAssertionCoverage": 60 }
}Later layers win: defaults, user config, project config, GAPIX_* environment
variables, command-line flags.
gapix config path shows which file was used. gapix doctor reports the
resolved environment.
Commands
gapix check [path] analyse (the default — bare `gapix` works too)
--fail-on <severity> exit 1 on critical|warn|info (default: never)
--min-assertion-coverage <n> exit 1 below n% (only when symbols exist)
--baseline <path> accept known findings; fail only on new ones
--update-baseline write the current findings as the baseline
--since <ref> only files changed since a git ref
--format <fmt> terminal|json|html|markdown|sarif
-o, --output <path> write there instead of the default (- is stdout)
gapix init [--ci] create a config, optionally a CI workflow
gapix rules list every rule
gapix explain <rule-id> the reasoning behind a rule
gapix report reopen the last HTML report
gapix config path|get|set inspect and change settings
gapix doctor report the resolved environmentgapix check --help lists every flag.
AI suggestions
Off by default. --ai adds written suggestions through OpenAI or a local
Ollama.
AI never moves a number and never raises a finding. That is enforced in the architecture, not by convention. A CI gate that answers differently on identical input is broken. With AI off, two runs over the same tree are byte-identical.
Compared to mutation testing
gapix is a fast first pass, not a replacement for Stryker. Stryker mutates your code, reruns your suite, and measures empirically what your tests catch. That is ground truth, and it costs minutes to hours.
gapix reads the AST in about a second and finds the gaps that need no execution. Run gapix on every commit. Run Stryker on what it flags.
Contributing
See CONTRIBUTING.md.
Adding support for a test framework is the highest-value contribution. Each one is a single file in src/dialects/ with its own fixture, and the guide walks you through it.
Requires Node 20 or newer. MIT © Art Shllaku.
