Skip to content

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=0
findings:
[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.

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.

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.18
0.18 × 1.5 = 0.27
1.0 − 0.27 = 0.73 → 7.30

Two 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.

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) / Σ weight
repo = 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.

Each signal carries a fixed severity and confidence. Both feed the formula above, so the table is the whole weighting model.

| 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.

| 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 and e.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 every InnerException. e.toString() in JavaScript is "Error: msg". Same spelling, opposite verdicts.

| 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=1

dropped-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.

| 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.

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.

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-catch
try { 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-continue
except 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 next lci-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.

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=2
undo_cost: uncompensated=1 irreversible_first=0
findings:
[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 more
density: findings=10 per_100_funcs=1.42
exposure:
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=3
next: code_insight {"mode":"detailed","analysis":"errors"}
  • 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 use density and the findings list for prioritization — a 9.99 with density=0.4 and a 9.99 with density=12.0 are different codebases.
  • modules: worst=… best=… — the module rollups at both extremes, each as package(score). The worst module also bounds the repo score (repo ≤ worst + 3.0): a package that swallows everywhere cannot be averaged away.
  • 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 of empty-catch, finally-discards-exception, catch-and-continue, error-to-sentinel, log-and-swallow. This is the count that feeds exposure:.
  • unchecked_errors — Go-specific dropped-error count (_ = f()).
  • suppressed — findings silenced by lci-disable* directives, printed only when non-zero: a silenced report must not look like a clean one.

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.

[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 |

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.

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.

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.

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.

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" } }