pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
96 lines (95 loc) • 3.99 kB
JavaScript
/**
* Flags async functions that call IO/network/DB boundaries but have no
* try/catch protection AND have non-trivial complexity.
*
* Rationale: boundary wrappers with high CC and no error handling are the
* most common source of unhandled-rejection crashes and silent data loss.
* Simple pass-throughs are excluded — they're expected to propagate errors.
*/
// Entries ending with '.' are namespace prefixes (startsWith match).
// All other entries are exact callee names — prevents e.g. "spawn" matching "spawned.map"
// or "fetch" matching "fetchWithRetry".
const IO_NAMESPACE_PREFIXES = [
"db.", "prisma.", "knex.", "mongoose.", "sequelize.",
"http.", "https.", "net.", "dns.",
"redis.", "mongo.", "pg.", "mysql.",
"s3.", "storage.", "bucket.",
"fs.promises.",
];
const IO_EXACT_CALLEES = new Set([
"fetch", "axios", "got", "request",
"spawn", "exec", "execSync", "spawnSync",
"readFile", "writeFile", "appendFile", "readdir", "mkdir", "unlink", "stat",
]);
const CC_THRESHOLD = 6; // raised from 4 — avoids flagging simple async wrappers with one IO call
function callsToBoundary(outgoingCalls) {
for (const callee of outgoingCalls) {
const lower = callee.toLowerCase();
if (IO_EXACT_CALLEES.has(lower))
return callee;
if (IO_NAMESPACE_PREFIXES.some((p) => lower.startsWith(p)))
return callee;
}
return undefined;
}
function hasCatchCoverage(fn, catches) {
// A catch block is considered covering if it is non-empty and does one of:
// - rethrows the error
// - logs it (console.error / logger.*)
// - has no binding (catch {} — intentional swallow)
// - returns a structured value (return { ... } / return false / return null)
// This handles the common "return structured error" pattern in IO helpers.
// Also handles Promise-executor pattern: if the function body itself resolves/rejects
// via new Promise((resolve) => {...}) we treat it as covered at the call site.
if (fn.outgoingCalls.some((c) => c === "resolve" || c === "reject"))
return true;
return catches.some((c) => {
if (c.line < fn.line)
return false;
if (c.isEmpty)
return false;
if (c.hasRethrow || c.hasLogging || c.catchParam === null)
return true;
// Catch body contains a return statement → structured error result
if (/\breturn\b/.test(c.bodyText))
return true;
return false;
});
}
export const unsafeBoundaryRule = {
id: "unsafe-boundary",
requires: ["file.functionSummaries", "file.tryCatchSummaries"],
appliesTo(ctx) {
return /\.tsx?$/.test(ctx.filePath);
},
evaluate(ctx, store) {
const fns = store.getFileFact(ctx.filePath, "file.functionSummaries") ?? [];
const catches = store.getFileFact(ctx.filePath, "file.tryCatchSummaries") ?? [];
const diagnostics = [];
for (const f of fns) {
if (!f.isAsync)
continue;
if (f.isPassThroughWrapper)
continue; // expected to propagate
if (f.cyclomaticComplexity < CC_THRESHOLD)
continue;
const boundaryCalle = callsToBoundary(f.outgoingCalls);
if (!boundaryCalle)
continue;
if (hasCatchCoverage(f, catches))
continue;
diagnostics.push({
id: `unsafe-boundary:${ctx.filePath}:${f.line}`,
tool: "fact-rules",
rule: "unsafe-boundary",
filePath: ctx.filePath,
line: f.line,
column: f.column,
severity: "warning",
semantic: "warning",
message: `'${f.name}' is async, calls '${boundaryCalle}', has complexity ${f.cyclomaticComplexity}, but no try/catch — unhandled rejection risk`,
});
}
return diagnostics;
},
};