axiom-scan
v3.3.0
Published
Find the invariants your codebase assumes but never tests
Downloads
5,300
Maintainers
Readme
axiom-scan
Find the invariants your codebase assumes but never tests.
85% line coverage doesn't mean your code is safe. It means 85% of your lines ran during tests — not that the assumptions those lines make have ever been challenged.
Every non-trivial codebase is full of implicit invariants:
user.subscriptionis nevernullbeforegetBillingPlan()is calledinitDB()always runs beforequery()mu.Lock()is always paired with a deferredmu.Unlock()- errors from network calls are always surfaced, never silently dropped
These assumptions are true by convention, not by contract. Nobody wrote them down. Nobody tests them. When they break — due to a refactor, a new code path, a missing defer — production breaks and the test suite stays green.
AXIOM reads your code statically, infers what it assumes to be true, diffs that against your test suite, and hands you a ranked list of the bets you're making on code that's never been verified.
Install
npm install -g axiom-scanOr run without installing:
npx axiom-scan scan ./srcDemo
$ axiom scan ./src
AXIOM v2.0.0 — static invariant analysis
target: /your/project
▸ TypeScript/JS 142 source files
▸ Go 91 source files
▸ Python 38 source files
271 source files · running inference...
invariants found:
null 47
concurrency 12
resource 9
swallowing 8
━━━ UNTESTED INVARIANTS (ranked by blast radius) ━━━
● CRITICAL src/api/payments.ts:88 [high confidence]
user.address assumed non-null
├─ relied on by 14 call sites
├─ nearest public entry: POST /checkout (2 hops)
└─ tests covering null path: 0
● CRITICAL src/workers/job.go:134 [high confidence]
`mu.Lock()` is not deferred — `Unlock` will not run on early return or panic
├─ relied on by 3 call sites
├─ nearest caller: processJob
└─ tests covering null path: 0
◐ HIGH src/payments/stripe.py:61 [medium confidence]
error logged but not re-raised in `except StripeError` block
├─ relied on by 6 call sites
├─ nearest public entry: POST /subscribe (1 hop)
└─ tests covering null path: 0
─────────────────────────────────────────────────────
76 invariants · scanned 312 files in 2.4s
14 critical · 25 high · 37 medium
null ×47 concurrency ×12 resource ×9 swallowing ×8
axiom explain <file:line> → remediation hintWhat it detects
| Type | Languages | What it catches |
|------|-----------|-----------------|
| null | All 8 | Property access on values that could be null/nil/undefined with no guard |
| ordering | All 8 | Fields or variables read before they are guaranteed to be initialized |
| shape | All 8 | UUID/type format assumed at point of use without validation |
| swallowing | All 8 | Errors caught and silently discarded — empty catch, log-only catch |
| concurrency | Go, Java, Python, C#, Ruby, Rust | Mutex leaks, unjoined threads, fire-and-forget tasks |
| resource | All 8 | File handles, DB connections, sockets opened without guaranteed close — including inter-procedural: wrapper functions that return handles are tracked across call sites |
Concurrency patterns
Go — mu.Lock() without defer mu.Unlock(); wg.Add() without wg.Wait()
Java — lock.lock() without finally { unlock() }; executor.submit() without shutdown()
Python — lock.acquire() without finally: lock.release() (prefer with lock:); Thread.start() without Thread.join()
C# — Monitor.Enter() without finally { Monitor.Exit() }; SemaphoreSlim.Wait() without finally { Release() }; Task.Run() result discarded (fire-and-forget)
Ruby — Thread.new without .join; mutex.lock without .unlock (prefer mutex.synchronize {})
Rust — let _ = mutex.lock() (guard dropped immediately, lock released at end of statement); thread::spawn handle not joined
Inter-procedural resource tracking (v2.0)
Resource analysis crosses function boundaries. If a function wraps os.Open and returns the handle, every call site that doesn't close the returned value is flagged:
// opener.go
func openConfig(path string) *os.File {
f, _ := os.Open(path)
return f // ← marked as resource-returning wrapper
}
// handler.go
func handleRequest(path string) {
f := openConfig(path) // ← FLAGGED: `defer f.Close()` missing
parseConfig(f)
}Wrapper functions are not flagged themselves. Two-hop chains are resolved (openRaw → openWrapped → caller). Works across files in the same scan.
Languages
| Language | Parser |
|----------|--------|
| TypeScript / JavaScript | @typescript-eslint/typescript-estree |
| Python | tree-sitter (WASM) |
| Go | tree-sitter (WASM) |
| Ruby | tree-sitter (WASM) |
| Java | tree-sitter (WASM) |
| Rust | tree-sitter (WASM) |
| C# | tree-sitter (WASM) |
| PHP | tree-sitter (WASM) |
Usage
# Scan current directory
axiom scan
# Scan a specific path
axiom scan ./src
# JSON output for CI pipelines
axiom scan --json > axiom-report.json
# SARIF output for GitHub code scanning
axiom scan --sarif > results.sarif
# Only show critical and high severity
axiom scan --min-severity high
# Exit with code 1 if any critical finding exists
axiom scan --fail-on critical
# Only scan files changed since a branch or commit
axiom scan --since main
axiom scan --since HEAD~5
# Watch mode
axiom scan --watch
# Explain a specific invariant with remediation hints
axiom explain src/billing.ts:47Inline suppressions
Add axiom-ignore on the flagged line or the line above:
// axiom-ignore
const plan = user.subscription.plan;Works in all supported languages using that language's comment syntax.
LSP server
axiom-lsp is included. It surfaces findings as diagnostics in VS Code, Neovim, Emacs, and any LSP-compatible editor with no extension required. Validates on file open and save.
Neovim (nvim-lspconfig):
require('lspconfig').axiom.setup({
cmd = { 'axiom-lsp', '--stdio' },
filetypes = { 'typescript', 'javascript', 'python', 'go', 'ruby', 'java', 'rust', 'cs', 'php' },
})Ranking
Each invariant is scored by blast radius:
score = (call_sites × 2)
+ entrypoint_proximity // closer to HTTP handler = higher score
+ type_weight // concurrency=4, null=3, resource=3, ordering=2, swallowing=2, shape=1
- test_coverage_discountSeverity buckets: CRITICAL (>20) · HIGH (10–20) · MEDIUM (5–10) · LOW (<5)
Configuration
Create .axiomrc.json in your project root:
{
"ignore": ["**/*.generated.ts", "src/migrations/**"],
"minScore": 5,
"maxResults": 100,
"patterns": {
"nullMethods": ["findBySlug", "fetchLatest"],
"nullFunctions": ["my_custom_fetch"],
"lifecycleMethods": ["onBoot", "warmUp"],
"assertionFunctions": ["invariant", "ensure", "checkNotNull"]
}
}| Field | Description |
|-------|-------------|
| ignore | Glob patterns to exclude |
| minScore | Minimum blast-radius score to include |
| maxResults | Cap total results (highest score first) |
| patterns.nullMethods | Extra method names that return null/nil/undefined |
| patterns.nullFunctions | Extra function names that return null |
| patterns.lifecycleMethods | Extra methods treated as secondary constructors for ordering checks |
| patterns.assertionFunctions | Functions that guarantee non-null (invariant, assert, etc.) |
CI / GitHub Actions
- name: Run AXIOM
run: npx axiom-scan scan --sarif > axiom.sarif
- name: Upload to GitHub Code Scanning
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: axiom.sarif
# Gate on critical findings
- name: Fail on critical
run: npx axiom-scan scan --fail-on criticalDiff mode for PRs — only scan changed files:
- name: AXIOM diff scan
run: npx axiom-scan scan --since origin/main --fail-on criticalWhy not...
| Tool | Gap | |------|-----| | Istanbul / V8 coverage | Measures line execution, not behavioral assumptions | | TypeScript strict mode | Catches declared-type nulls only, not behavioral invariants | | ESLint | Rule-based — you write the rules; AXIOM infers them | | Semgrep | Pattern matching — you write the patterns; AXIOM infers them | | Mutation testing | Slow, requires tests to exist, doesn't find untested assumptions |
License
MIT
