Skip to content

Commit af054d8

Browse files
committed
feat: implement Phase R Wave 1 — five cross-signal diagnostic rules
R.1 sync-in-hot-path: escalates readFileSync/writeFileSync to critical when called inside an active request context (via AsyncLocalStorage). R.2 missing-connection-pool: TypeScript AST walk at startup detects new Client() / createConnection() inside function bodies, flags them as warning with a fix suggestion to move to module scope. R.3 correlated-slow-endpoint: emits anomaly when a slow outbound HTTP request (>1s) and an active N+1 pattern share the same W3C traceId. R.4 pool-starvation-by-slow-query: emits anomaly when pool-exhaustion fires within 10s of a slow query on the same driver, surfacing the likely culprit holding connections. R.5 n-plus-one-in-transaction: escalates N+1 detection to critical when the pattern fires inside an open transaction (same traceId/ correlationId), where repeated queries also delay COMMIT. 541 tests pass. 0 TS errors. 0 ESLint errors. Prettier clean.
1 parent 97cb7be commit af054d8

11 files changed

Lines changed: 799 additions & 3 deletions

File tree

packages/agent/src/analysis/fs-analyzer.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export class FsAnalyzer {
55
private readonly READ_WINDOW_MS = 1000;
66
private readonly READ_THRESHOLD = 5;
77

8-
public analyze(methodName: string, filePath: string): FixSuggestion[] {
8+
public analyze(methodName: string, filePath: string, insideRequest?: boolean): FixSuggestion[] {
99
const suggestions: FixSuggestion[] = [];
1010

1111
// 1. Sync Method Detection - Event Loop Blockers
@@ -16,6 +16,15 @@ export class FsAnalyzer {
1616
message: `Usage of ${methodName} blocks the entire Node.js Event Loop. A slow disk read pauses all other requests.`,
1717
suggestedFix: `Replace ${methodName} with the equivalent promise-based fs.promises.${methodName.replace("Sync", "")}() and await it.`,
1818
});
19+
20+
if (insideRequest) {
21+
suggestions.push({
22+
severity: "critical",
23+
rule: "sync-in-hot-path",
24+
message: `${methodName} called inside a live request handler — blocks the event loop for every concurrent request.`,
25+
suggestedFix: `Replace with fs.promises.${methodName.replace("Sync", "")}() and await it.`,
26+
});
27+
}
1928
}
2029

2130
// 2. Path Traversal Risks

packages/agent/src/analysis/static-scanner.ts

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,19 @@ interface TsDiagnostic {
2323
code: number;
2424
messageText: string | TsDiagnosticMessageChain;
2525
}
26+
interface TsNode {
27+
kind: number;
28+
pos: number;
29+
end: number;
30+
}
31+
interface TsSourceFile extends TsNode {
32+
fileName: string;
33+
getLineAndCharacterOfPosition(pos: number): { line: number; character: number };
34+
}
2635
interface TsProgram {
2736
getSyntacticDiagnostics(): readonly TsDiagnostic[];
2837
getSemanticDiagnostics(): readonly TsDiagnostic[];
38+
getSourceFiles(): readonly TsSourceFile[];
2939
}
3040
interface TsApi {
3141
sys: TsSystem;
@@ -48,6 +58,16 @@ interface TsApi {
4858
messageText: string | TsDiagnosticMessageChain,
4959
newLine: string,
5060
): string;
61+
SyntaxKind: {
62+
readonly NewExpression: number;
63+
readonly CallExpression: number;
64+
readonly FunctionDeclaration: number;
65+
readonly FunctionExpression: number;
66+
readonly ArrowFunction: number;
67+
readonly MethodDeclaration: number;
68+
readonly Constructor: number;
69+
};
70+
forEachChild<T>(node: TsNode, cbNode: (node: TsNode) => T | undefined): T | undefined;
5171
}
5272

5373
/**
@@ -85,7 +105,7 @@ export class StaticScanner extends EventEmitter {
85105
}
86106

87107
/**
88-
* Run a full scan (TypeScript + ESLint if available).
108+
* Run a full scan (TypeScript + ESLint + connection-pool static rules if available).
89109
*/
90110
public async scan(): Promise<ScanResult[]> {
91111
const results: ScanResult[] = [];
@@ -98,6 +118,11 @@ export class StaticScanner extends EventEmitter {
98118
results.push(eslintResult);
99119
}
100120

121+
const poolResult = await this.runConnectionPoolScan();
122+
if (poolResult && poolResult.totalIssues > 0) {
123+
results.push(poolResult);
124+
}
125+
101126
this.emit("scan", results);
102127
return results;
103128
}
@@ -250,6 +275,113 @@ export class StaticScanner extends EventEmitter {
250275
});
251276
}
252277

278+
/**
279+
* R.2 — Scan TypeScript source files for connection constructors called inside
280+
* function bodies (missing-connection-pool). Uses the TypeScript Compiler API
281+
* to walk the AST; returns null if TypeScript is not available.
282+
*/
283+
public async runConnectionPoolScan(): Promise<ScanResult | null> {
284+
const start = performance.now();
285+
286+
try {
287+
const tsId = "typescript";
288+
const tsModule: unknown = await import(tsId);
289+
const ts = ((tsModule as { default?: TsApi }).default ?? tsModule) as TsApi;
290+
291+
const configPath = ts.findConfigFile(
292+
this.targetDir,
293+
(p: string) => ts.sys.fileExists(p),
294+
"tsconfig.json",
295+
);
296+
if (!configPath) return null;
297+
298+
const { config, error: readError } = ts.readConfigFile(
299+
configPath,
300+
(p: string, enc?: string) => ts.sys.readFile(p, enc),
301+
);
302+
if (readError) return null;
303+
304+
const parsed = ts.parseJsonConfigFileContent(config as object, ts.sys, dirname(configPath));
305+
const program = ts.createProgram(parsed.fileNames, { ...parsed.options, noEmit: true });
306+
307+
const suggestions = this.detectConnectionInFunction(ts, program);
308+
309+
return {
310+
tool: "argus-static",
311+
totalIssues: suggestions.length,
312+
suggestions,
313+
durationMs: performance.now() - start,
314+
};
315+
} catch {
316+
return null;
317+
}
318+
}
319+
320+
private detectConnectionInFunction(ts: TsApi, program: TsProgram): FixSuggestion[] {
321+
const suggestions: FixSuggestion[] = [];
322+
323+
const CONNECTION_CTORS = new Set([
324+
"Client",
325+
"Connection",
326+
"Sequelize",
327+
"MongoClient",
328+
"createConnection",
329+
"createPool",
330+
]);
331+
332+
const FUNCTION_KINDS = new Set([
333+
ts.SyntaxKind.FunctionDeclaration,
334+
ts.SyntaxKind.FunctionExpression,
335+
ts.SyntaxKind.ArrowFunction,
336+
ts.SyntaxKind.MethodDeclaration,
337+
ts.SyntaxKind.Constructor,
338+
]);
339+
340+
const walk = (node: TsNode, sourceFile: TsSourceFile, insideFunction: boolean): void => {
341+
const enterFunction = FUNCTION_KINDS.has(node.kind);
342+
const nowInside = insideFunction || enterFunction;
343+
const kindNew = ts.SyntaxKind.NewExpression;
344+
const kindCall = ts.SyntaxKind.CallExpression;
345+
346+
if (insideFunction && (node.kind === kindNew || node.kind === kindCall)) {
347+
const expr = (node as { expression?: { text?: string } }).expression;
348+
const name = expr?.text;
349+
350+
if (name && CONNECTION_CTORS.has(name)) {
351+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.pos);
352+
const prefix = node.kind === kindNew ? `new ${name}()` : `${name}()`;
353+
suggestions.push({
354+
severity: "warning",
355+
rule: "missing-connection-pool",
356+
message: `${prefix} called inside a function body — creates a new connection per call instead of reusing a pool.`,
357+
suggestedFix:
358+
"Move the client/pool instantiation to module scope and reuse it across requests.",
359+
location: `${sourceFile.fileName}:${line + 1}:${character + 1}`,
360+
});
361+
}
362+
}
363+
364+
ts.forEachChild(node, (child) => {
365+
walk(child, sourceFile, nowInside);
366+
return undefined;
367+
});
368+
};
369+
370+
for (const sourceFile of program.getSourceFiles()) {
371+
if (
372+
sourceFile.fileName.includes("node_modules") ||
373+
sourceFile.fileName.endsWith(".d.ts") ||
374+
sourceFile.fileName.endsWith(".test.ts") ||
375+
sourceFile.fileName.endsWith(".spec.ts")
376+
)
377+
continue;
378+
379+
walk(sourceFile as TsNode, sourceFile, false);
380+
}
381+
382+
return suggestions;
383+
}
384+
253385
/**
254386
* Parse TypeScript compiler output into FixSuggestions.
255387
* Format: `filepath(line,col): error TSxxxx: message`

0 commit comments

Comments
 (0)