Error-Handling Score
Ask code_insight for the error-handling section and you get a number, the
modules at either end of it, and the evidence behind it:
== ERROR HANDLING ==score=7.30 modules: worst=(root)(7.3) best=(root)(7.3)throwers=0 handled_ratio=0.00 swallow_sites=1 unchecked_errors=0findings: [high] empty-catch: handle (svc.js:2) [o=B]That run analyzed one file:
export function handle(req) { try { return work(req); } catch (e) { }}This page explains where 7.30 came from, what each signal means, and which claims the analysis refuses to make.
Enabling the beta
Section titled “Enabling the beta”The report is gated by insight.error_report, with three modes:
| Mode | Report sections | Detailed modes | Capture file |
|---|---|---|---|
| off (default) | absent | refused with an error naming the gate | never |
| capture | absent | refused | written at MCP session end |
| on | emitted | served | never (already published in-band) |
In .lci.kdl (project) or ~/.config/lci/config.kdl (user-wide defaults —
the right place for a batch driver that runs lci over many repos):
insight { error_report "capture"}The environment variable LCI_ERROR_REPORT=off|capture|on beats both files,
for one-off runs. An invalid value fails config loading — it is never
silently ignored.
What each mode gates. off/capture remove every surface at once: the
== ERROR HANDLING == and == RESOURCE MANAGEMENT == sections in
unified/overview, the error_handling=… resources=… headline in
== SUMMARY ==, the error_handling/resources objects in the
side_effects summary JSON, and the detailed analysis=errors|resources
modes (those return an explicit error naming the gate rather than an empty
section).
Capture: generate, don’t publish. In capture mode an MCP session
computes the full, untruncated report once — after the transport exits, so
no request ever pays for it — and writes it atomically to:
$XDG_STATE_HOME/lci/error-reports/<root-slug>.txt # ~/.local/state fallback<root-slug> is the project root with every non-[a-zA-Z0-9.-] character
replaced by -. The file opens with a provenance header (root=,
generated_by=lci <version>, the mode) and then carries the two sections in
exactly the format documented below, with no finding-count truncation. A
corpus that produced no side-effect records writes an explicit
“no side-effect records” line — an empty capture never masquerades as a
clean one. This is the mode for batch drivers (err-lookup): every run leaves
a report on disk, and none of the runs’ published output changes.
The arithmetic, once
Section titled “The arithmetic, once”Every finding subtracts from a function’s score, which starts at 1.0:
deduction = severity_base × confidence × (0.5 + 0.5 × normalized_fan_in) × contract_weight
function_score = max(0, 1.0 − Σ deductions)Severity bases are high = 0.4, med = 0.25, low = 0.1. For the example
above: empty-catch is high severity at 0.9 confidence, handle is the only
function so its fan-in is 0, and it is exported, which applies the 1.5x
contract weight.
0.4 × 0.9 × (0.5 + 0.5 × 0) = 0.180.18 × 1.5 = 0.271.0 − 0.27 = 0.73 → 7.30Two multipliers shape that number, and both encode a judgment worth stating outright.
Fan-in. The (0.5 + 0.5 × normalized_fan_in) term means a swallow in a
leaf function costs half what the same swallow costs in the most-called
function in the codebase. Fan-in comes from the real call graph, the same
transitive reach the LOAD BEARING section reports, normalized against the
highest reach in the corpus.
Library contract. A function on the public surface owes its callers one of
two things about a failure: bubble it up, or transform it into this library’s
own error and hand that up. Swallowing deletes a failure the caller has no
other way to observe, and no amount of caller-side diligence recovers it. So
exported functions carry kExportedSwallowMultiplier = 1.5. It scales the
deduction rather than adding a second finding, and a clean exported function
scores exactly as well as a clean private one.
Rolling up to the repo
Section titled “Rolling up to the repo”Function scores aggregate by call-graph weight, so heavily-called code moves the number more than a leaf does:
weight = 1 + log₂(1 + reach)repo = 10 × Σ(weight × function_score) / Σ weightrepo = min(repo, worst_module_score + 3.0)That last line stops a mean from averaging away a package that swallows everywhere. A repo with one terrible module and a hundred clean ones is capped at three points above the terrible one.
Signals
Section titled “Signals”Each signal carries a fixed severity and confidence. Both feed the formula above, so the table is the whole weighting model.
The failure stopped here
Section titled “The failure stopped here”| Signal | Severity | Confidence |
|---|---|---|
| empty-catch | high | 0.9 (med 0.7 on a typed catch) |
| finally-discards-exception | high | 0.85 |
| catch-and-continue | high | 0.7 (med 0.5 on a typed catch) |
| error-to-sentinel | med | 0.7 (0.5 on a typed catch) |
| log-and-swallow | med | 0.75 message-only, 0.5 full error |
These five make up swallow_sites, and they are the set that feeds the
exposure: paths, meaning public API symbols that transitively reach a
swallow, found by reverse BFS bounded at 6 hops.
finally-discards-exception is the one signal that reports what the code does
rather than what its author probably meant. A return inside finally
discards whatever exception was propagating through it, in every language that
has the construct, and there is no catch site for a reader to notice. Hence the
0.85.
error-to-sentinel covers catch (e) { return null; }. The failure became
“no result”, and the caller cannot tell an empty answer from a broken one. It
sits at med rather than high because returning a sentinel is a real contract in
lookup-style APIs.
The failure moved on, damaged
Section titled “The failure moved on, damaged”| Signal | Severity | Confidence |
|---|---|---|
| message-only-propagation | med | 0.5 |
| rethrow-no-cause | low | 0.4 |
| broad-catch | med | 0.6 |
message-only-propagation fires when the error leaves the block but only its
message does: callback(e.message) forwards a sentence and drops the stack and
the cause chain. Partial credit: forwarding beats swallowing, and the
deduction is well under catch-and-continue’s.
Whether a projection is lossy depends on the language rather than the spelling:
err.Error()in Go ande.what()in C++ return everything those error types carry. Nothing exists to lose, so they count as full fidelity..message,.Message,getMessage()in JavaScript, Python, Java and C# discard a stack and a cause chain that do exist.ex.ToString()in .NET renders the message, the stack, and everyInnerException.e.toString()in JavaScript is"Error: msg". Same spelling, opposite verdicts.
The failure cost something
Section titled “The failure cost something”| Signal | Severity | Confidence |
|---|---|---|
| uncompensated-transaction | high | 0.8 |
| partial-write-risk | high | 0.6 |
| dropped-error | low | 0.5 |
| irreversible-before-fallible | low | 0.5 |
Reported on their own line, and counted apart from swallow_sites because a
function can report an error perfectly and still lose the data:
undo_cost: uncompensated=2 irreversible_first=1dropped-error is Go-specific: _ = doWork() discards the sole result of a
call. Only that form fires, and at low: the explicit blank is the developer
writing “I know”, which is why errcheck, the Go community’s own tool, excludes
it unless asked (-blank). pocketbase’s 29 were all cleanup or best-effort
calls. Inside a defer it is the cleanup idiom and not reported. A blank in a
multi-value assign (v, _ := f()) is as often an ok-bool as an error and
never fires. The form errcheck does report by default, a bare f() statement
whose error return is never bound, needs return types this analysis does not
have; that is a known gap, not a refusal.
uncompensated-transaction fires on a begin and a commit with no rollback
on any path. partial-write-risk needs two or more durable state changes with
a fallible point among them and no compensation. That is the torn write,
including the common case where no transaction exists at all.
irreversible-before-fallible is ordering rather than a defect: work that
cannot be recalled running before work that can still throw. Charging a card
and then validating the order leaves something to undo; validating first leaves
nothing. It scores low because it is advice.
Resources
Section titled “Resources”| Signal | Severity | Confidence |
|---|---|---|
| leak-no-release | med | 0.6 |
| leak-on-error-path | med | 0.5 |
| unguarded-release | low | 0.4 |
These roll up into a separate == RESOURCE MANAGEMENT == score under the same
formula.
What the analysis will not claim
Section titled “What the analysis will not claim”There is no dataflow or CFG layer. Detection is syntactic, name-aware, and propagated over the call graph, which bounds what any finding can mean: lci can say “no release is syntactically visible on any path”, never “this cannot leak”. Calibrating against express, axios, zod, flask, requests, gin and Next.js turned that bound into a set of specific refusals.
An error passed to a call has been handled. catch (err) { callback(err) }
and catch (error) { promise = Promise.reject(error) } are propagation.
Treating them as swallows made express’s only finding and four of axios’s five
false. Logging is excluded from that rule: console.error(e) passes the cause
as an argument but hands it to nobody, which is what log-and-swallow
describes.
A name that promises a sentinel makes the sentinel the answer.
isValidIPv6 returning false is correct: false is the answer, and the
exception was the mechanism for computing it. tryStat returning undefined
is the documented contract of every try-prefixed API. The is, has, can, try and check prefixes are recognized when
followed by a word boundary, so
issue() and canvas() remain ordinary functions, along with the
*Safely / *OrNull / *OrDefault suffix family.
An error that leaves the block any way at all has left. failure = e,
result = [wrap(cause), undefined], errors.push(cause), yield format(cause), new TRPCError({ cause }): a store, a collection, a
generator, a constructor. Each hands the error to whoever reads the binding.
The first calibration round covered only call arguments; trpc’s
resolveResponse, jsonl producer and mergeAsyncIterables were all reported
as swallows for storing the error instead of throwing it. This also settles
the order of the sentinel rule: a return [] after the error was wrapped and
reported is the value of a handled failure, not a renamed one.
A callee named error is not a logger. getTRPCErrorFromUnknown(cause)
wraps, isAbortError(cause) tests, errors.append(...) collects,
opts.onError(...) forwards. The log credit goes to logging verbs, to a
logger’s level methods (log.error, logger.fatal), and to reporters that
compound a handling verb with the word (checkApiError, handleError,
captureException). Nothing else.
A typed catch of a normal condition is the protocol, not a swallow.
fastapi’s keepalive loop is except TimeoutError: send(KEEPALIVE); its stream
reader ends on except EndOfStream: pass; the websocket tutorial cleans up on
except WebSocketDisconnect. The exception type names the expected end of
something, and the handler is what happens then. Catch headers naming
Timeout, Disconnect, EndOfStream, EOFError, StopIteration,
Cancel, Abort, Interrupt or BrokenPipe produce no swallow signal. A
typed catch of an actual failure (except IOError: retry()) still does.
Breadth is a defect only when it loses something. except Exception as e: raise HTTPException(400) from e is deliberately broad, and the cause chain
survives. broad-catch is charged only when the block neither re-raises with
the cause nor forwards the whole error. fastapi had six of these; every one
was a chained re-raise.
A bare create* call is a factory. trpc’s router builder makes ten
createLazyLoader(...) calls into a local record, which read as a ten-way
torn write. In JS and TS an unqualified createX(...) constructs far more
often than it persists. A receiver (db.createOrder) is what says the thing
outlives the call, and only the qualified form counts toward
partial-write-risk.
The caught variable’s name is the developer’s verdict. catch (NumberFormatException ignored) in gson, catch (_: Exception) in okhttp.
IntelliJ, detekt and checkstyle all read ignored, _, unused and
expected as the explicit discard marker, and so does lci: no swallow signal
fires on that site.
The function’s name is its contract. okhttp’s closeQuietly,
ignoreIoExceptions, toHttpUrlOrNull and buildIfSupported promise that
failures are absorbed; the catch inside is the promise being kept. The sentinel
rule has read the is/try/*OrNull family since express and zod; the same
names, plus ignore*, *Quietly, *Silently and *IfSupported, now clear
every swallow signal in the function, whatever the catch body looks like.
Teardown may not throw. .NET’s Dispose guideline, PHP’s __destruct,
Java’s close(): a throw from cleanup masks the original failure or kills a
finalizer, so catch-and-continue there is the documented behavior of the
method. serilog’s sinks and guzzle’s curl handler both do it on purpose.
Findings inside Dispose, __destruct, close, finalize, shutdown,
teardown and cleanup are reported, capped at low.
A typed catch is the anticipated-failure shape. except NameError: signature = fallback(call), catch (NumberFormatException e) { return -1; },
catch (const json::exception&) { /* litter */ } each name the failure the
author expected and write what happens then. The cause is still gone, so
catch-and-continue, error-to-sentinel and empty-catch still fire, at med
and with the confidence cut (detail typed recovery / typed). A bare
catch (e) { recover(); }, catch (...) { } or except: pass stays high.
Typed means the header names a type, not that the name is capitalized:
nlohmann::json::exception counts.
C++ logs through a stream. std::cerr << "dropping: " << e.what() is a
log call for the purposes of log-and-swallow, with what() at full
fidelity.
A verb needs a receiver that looks like a store. line.begin() is an
iterator, std::cout.flush() drains a stream, crypto.createHash() is a
factory, app.add_option() is a CLI builder (lci’s own main() read as 45
torn writes). flush, create*, add_* and register* count as durable
only on a receiver named like one: db, repo, session, tx, store,
client, service, collection, queue and their compounds. commit and
beginTransaction never needed one.
A handler that logs and then recovers has reported the error. Logging
beside other work used to read as a blind catch-and-continue at high. The
error was reported, then handled: that is log-and-swallow.
Mutually exclusive branches are not a sequence. gin’s SetMode stores four
modes in four switch cases around a panic. Only one arm runs, so they cannot
leave each other half-applied. State changes are grouped by their enclosing arm
before anything is counted. Exception handlers are arms too: at most one
rescue/except/catch clause runs per raise, so rack’s two one-line rescue
arms are alternatives, not a two-change torn write.
A cause chained before the throw is still chained. Java’s
npe.initCause(e); throw npe; keeps the whole chain — the throw statement
just never mentions e. An initCause(e) anywhere in the catch body counts
as the rethrow carrying the cause. RxJava spells every subscribe() this way.
A fatal guard is a conditional rethrow. Exceptions.throwIfFatal(ex)
re-propagates Errors and fatal exceptions with their cause; what remains is
forwarded whole. A catch (Throwable) built around one is exactly as broad as
its guard, so neither broad-catch nor rethrow-no-cause applies. This
cleared 687 of RxJava’s 699 findings; the 38 that remain hide the guard one
helper call deep (fail(ex)), which the syntactic layer refuses to chase.
A setter is an assignment, and “post” needs a store. Ruby spells
frame.post_context = v as a setter call named post_context=, which
prefix-matched the publish verb and made rack’s exception-page renderer a
torn write. A callee ending in = never reaches the work classifier, and
post joins flush/create in the receiver-gated set: English “post-”
(after — post_process, post_init) is a false friend of the HTTP verb, so
only client.post(...)-shaped calls keep it.
A finalizer is teardown. Python’s __del__ joins Dispose,
__destruct, close, finalize in the may-not-throw family: click’s
TextIOWrapper.__del__ swallowing its detach() failure is the documented
behavior, reported and capped at low.
A bare verb is not durable work. history.insert(0, r),
rows.insert(0, headers) and values.update(...) are collection calls on
locals, and zod’s z.email() builds a validator rather than sending mail. Only
a compound domain verb — insertChild, updateInventory, sendEmail — names
something that outlives the call. This one carries a deliberate false negative:
db.save(obj) spells a genuine durable write with a bare verb and is missed. A
wrong torn-write claim sends someone hunting for data loss that never happened,
which costs more than the miss.
The compensating action is checked for existence, not correctness. A
rollback() in a catch clears the finding whether or not it covers the
writes that actually happened, and a rollback that itself throws is invisible.
Proving otherwise needs dataflow.
Telling it to stop
Section titled “Telling it to stop”Some false positives are unavoidable in a syntactic analysis, and a reader who has checked a site should not have to see it again. The directive vocabulary is the one every linter shares; it works inside any comment syntax:
// lci-disable-next-line empty-catchtry { close(); } catch { }
try { close(); } catch { } // lci-disable-line
/* lci-disable partial-write-risk, broad-catch */function migrate() { ... }/* lci-enable */# lci-disable-next-line catch-and-continueexcept NameError: signature = fallback_signature(call)lci-disable-next-line <rules>covers the following line.lci-disable-line <rules>covers its own line.lci-disable <rules>covers every line until the nextlci-enable, or to the end of the file.
Rule names are the signal names printed in findings: (empty-catch,
log-and-swallow, partial-write-risk, and so on; the tables above are the
full list). Separate several with commas or spaces. A directive with no rule
list disables every rule on its range. An unknown rule name is inert, so a
directive written for a newer lci does not break an older one.
A suppressed finding is gone from the findings list, the score and the
exposure: paths. The header line says how many were silenced
(suppressed=3, omitted when zero), so a silenced report never reads as a
clean one. Comment the reason next to the directive the way you would for any
other linter.
Choosing what gets scored
Section titled “Choosing what gets scored”Analysis covers files whose attribute activates the analysis capability,
which means production alone until a project says otherwise. The header states the set,
and == SUMMARY == names what was left out:
excluded_from_analysis: test=195 (tests/ benchmarks/) benchmark=68 (benchmarks/) docs=1 (docs/)Point it elsewhere with the attributes argument:
{ "name": "code_insight", "arguments": { "mode": "unified", "attributes": ["test", "benchmark"] }}See MCP Server for the attributes block that defines these
tags per project.
Reading a report — every line, every field
Section titled “Reading a report — every line, every field”A full section, annotated line by line:
== ERROR HANDLING ==score=9.76 modules: worst=utils/lru(9.5) best=utils/hyperloglog(10.0)throwers=8 handled_ratio=0.75 swallow_sites=7 unchecked_errors=0 suppressed=2undo_cost: uncompensated=1 irreversible_first=0findings: [high] empty-catch: testit (utils/lru/test-lru.rb:57) [o=Eah] [med] log-and-swallow: process (svc/loader.py:244) caught=IOError, level=debug [o=Ff] ... and 8 moredensity: findings=10 per_100_funcs=1.42exposure: api-reaches-swallow: echo (src/click/utils.py:252) -> _is_binary_writer swallow depth=2 log=none api-reaches-cause-loss: TryConvert (src/Convert.cs:399) -> WrapAll rethrow-no-cause depth=3next: code_insight {"mode":"detailed","analysis":"errors"}The headline
Section titled “The headline”score=— 0.00–10.00, the weighted repo rollup from the arithmetic. 10.00 is reserved for zero findings. A large mostly-clean corpus whose weighted mean rounds to 10.0 while findings stand is capped at 9.99, so a dashboard can trust that 10.00 literally means “nothing found in scored code”. Treat the score as a delta gate (did this change make it worse?), and usedensityand the findings list for prioritization — a 9.99 withdensity=0.4and a 9.99 withdensity=12.0are different codebases.modules: worst=… best=…— the module rollups at both extremes, each aspackage(score). The worst module also bounds the repo score (repo ≤ worst + 3.0): a package that swallows everywhere cannot be averaged away.
The counters
Section titled “The counters”throwers— functions that can throw (syntactically: a throw/raise or a callee known to).handled_ratio— of the throwers, the fraction with a catch site in themselves or in a transitive caller within 6 call-graph hops. There is no universal “good” value — Ruby idiom sits far lower than Go idiom; compare a repo against itself over time, not across languages.swallow_sites— functions where a failure can stop: at least one ofempty-catch,finally-discards-exception,catch-and-continue,error-to-sentinel,log-and-swallow. This is the count that feedsexposure:.unchecked_errors— Go-specificdropped-errorcount (_ = f()).suppressed— findings silenced bylci-disable*directives, printed only when non-zero: a silenced report must not look like a clean one.
undo_cost:
Section titled “undo_cost:”Printed only when non-zero. uncompensated counts committed transactions
with no rollback path plus torn multi-write sequences; irreversible_first
counts irreversible-work-before-fallible-work orderings. Independent of the
swallow counters — a function can report an error perfectly and still lose
the data.
findings: — anatomy of one line
Section titled “findings: — anatomy of one line”[med] log-and-swallow: process (svc/loader.py:244) caught=IOError, level=debug [o=Ff] │ │ │ │ │ │ │ │ │ │ │ └ object id: get_context {"id":"o=Ff"} drills in │ │ │ │ └ detail: signal-specific evidence (see below) │ │ │ └ root-relative file:line of the finding │ │ └ enclosing function │ └ signal name (the tables above) └ severity after caps (teardown methods cap at low)Ordering is deterministic: severity desc, then file, then line. The unified
view shows 5 and prints ... and N more; detailed shows all.
Detail vocabulary by signal:
| Detail | On | Means |
|---|---|---|
| caught=<Type> | any catch signal | the catch header’s type (caught=thrown for a bare rethrow site) |
| typed / typed recovery | empty-catch, error-to-sentinel, catch-and-continue | the header names a type — anticipated-failure shape, confidence cut applied |
| message only, stack and cause chain lost | message-only propagation | only .message-shaped data left the block |
| level=error\|warn\|info\|debug\|print | log-and-swallow | the strongest log call’s severity. Production log configs routinely drop debug and info, and print is an unleveled sink (console.log, puts, std::cerr) that may not be shipped at all — a swallow logged at level=debug is invisible exactly when the incident happens. Annotation only: severity is not (yet) adjusted by level. |
| N state changes through line L, no compensation | partial-write-risk | the torn-write span |
| commit at line L, no rollback on any path | uncompensated-transaction | |
| <callee> cannot be recalled; line L can still fail | irreversible-before-fallible | |
| acquire=<callee> | resource signals | the acquisition site |
density:
Section titled “density:”Printed when findings exist: findings=<count> per_100_funcs=<rate> over
functions_scored. This is the prioritization number the saturating score
cannot give you — track it per module over time and burn it down.
exposure: — the troubleshooting map
Section titled “exposure: — the troubleshooting map”Two kinds of line, both produced by a reverse call-graph BFS (6-hop bound) from every sink at once, ranked by the API symbol’s transitive-caller reach, capped at 3 per kind:
api-reaches-swallow: <api> (<loc>) -> <sink> swallow depth=<d> log=<x>
A public-surface symbol from which a failure can reach a function that
deletes it. depth is call-graph hops from the API to the sink. log= is
what a production log will hold when the swallow fires, derived from the
sink’s own findings — none (nothing is logged: the silent-failure case
that makes incidents undiagnosable), message (one sentence, no stack), or
full (the whole error). Pre-incident hardening: fix the log=none paths
with the widest reach first.
api-reaches-cause-loss: <api> (<loc>) -> <sink> rethrow-no-cause depth=<d>
The funnel map. The sink catches and rethrows a new error without
chaining the cause, so an error surfacing from this API arrives renamed,
with its original stack and cause chain destroyed at the sink. Incident
triage reads this backwards: an error reported at <api> may have
originated as any failure below <sink> — go look there, not at the
surfaced type. Sinks that also swallow are seeded in the first list only.
== RESOURCE MANAGEMENT ==
Section titled “== RESOURCE MANAGEMENT ==”Same formula over the resource signals. acquisitions counts acquire
sites; released_ratio is the fraction of acquiring functions with any
release or fully-guarded acquires; guarded_ratio is the fraction of
release calls inside finally/defer/ensure/RAII scope. Findings and
density read exactly as above.
The JSON twin
Section titled “The JSON twin”side_effects {"mode":"summary"} carries the same rollups as
machine-readable error_handling / resources objects — every counter
above, plus per-finding severity, signal, symbol, file, line,
location, object_id, detail, confidence. This is the ingestion
path for tooling; the LCF sections are the human path.
Drilling in
Section titled “Drilling in”Every finding carries an object id:
{ "name": "get_context", "arguments": { "id": "o=Ff" } }and the section’s next: line names the detailed mode that lists every
finding untruncated:
{ "name": "code_insight", "arguments": { "mode": "detailed", "analysis": "errors" } }