Skip to content

Commit 52b32e6

Browse files
sharon77242claude
andcommitted
feat: implement Phase R Wave 2 and demo app Phase R scenarios
Wave 2 — three new cross-codebase diagnostic rules: R.4 query-in-loop (source-analyzer.ts): TypeScript AST walker detects DB method calls (query, execute, findMany, find, etc.) inside any loop or iteration callback (.map, .forEach, .filter, .reduce, …). Integrated into StaticScanner.runQueryInLoopScan() → fires at startup alongside the existing tsc/ESLint/pool-scan passes. R.5 missing-index-hint (migration-scanner.ts + index-hint-analyzer.ts): MigrationScanner parses CREATE INDEX / CREATE UNIQUE INDEX from SQL files and @@index([…]) from Prisma schemas into a table→columns index map. IndexHintAnalyzer tracks per-query frequency in a sliding window; when a query exceeds the threshold (default 100/min) against an un-indexed WHERE column it emits a concrete CREATE INDEX suggestion. Zero false positives when no migration files exist. Enabled via .withIndexHints(dir?). R.6 endpoint-never-called (route-tracker.ts): RouteTracker accepts registered routes detected by a regex scanner (Express/Fastify patterns) and records inbound hits via createMiddleware(). After a configurable warmup it emits an endpoint-never-called anomaly for every route with zero traffic. Enabled via .withRouteTracking(dir?, warmupMs?). Auto-wired for web app type in dev/test via profile-factory. Demo app — Phase R live scenarios: - app.js wires agent.createMiddleware() for W3C traceId propagation. - GET /debug/correlated-slow: N+1 × 6 + 1 100ms delay on same traceId → correlated-slow-endpoint (critical). - GET /debug/n-plus-one-in-txn: BEGIN + N+1 × 6 + COMMIT → n-plus-one-in-transaction (critical). - GET /debug/sync-read now also fires sync-in-hot-path (critical) because createMiddleware() makes the request context visible. - diagnostic.js anomaly handler prints full suggestions array. - traffic.js updated with three new Phase R traffic scenarios. Quality: 570 tests, 0 TS errors, 0 ESLint errors, Prettier clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bd39b6a commit 52b32e6

24 files changed

Lines changed: 1261 additions & 4 deletions

CHANGELOG.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
## [Unreleased]
1111

1212
### Added
13+
- **Phase R Wave 2 — Static + runtime intelligence rules**: Three new rules that combine
14+
static codebase knowledge with runtime frequency data to surface issues no single monitor
15+
can detect alone.
16+
- **`query-in-loop`** (`warning`) — `SourceAnalyzer` walks the TypeScript AST of every
17+
source file at startup. When a known DB method call (`query`, `execute`, `findMany`,
18+
`find`, etc.) appears inside a loop (`for`, `while`, `do`, `for…of`, `for…in`) or an
19+
iteration callback (`.map()`, `.forEach()`, `.filter()`, `.reduce()`, …), it emits a
20+
`query-in-loop` suggestion with the exact file:line location. Wired into
21+
`StaticScanner.scan()` via the new `runQueryInLoopScan()` method.
22+
- **`missing-index-hint`** (`warning`) — `MigrationScanner` parses SQL migration files
23+
(`CREATE INDEX` / `CREATE UNIQUE INDEX`) and Prisma schema files (`@@index([…])`) at
24+
startup to build a `table → Set<column>` index map. `IndexHintAnalyzer` then tracks
25+
per-query execution frequency in a sliding window; when a query exceeds the threshold
26+
(default 100/min) against an un-indexed `WHERE` column, it emits a concrete
27+
`CREATE INDEX` suggestion. Only fires when migration files were found — no false
28+
positives when the index map is empty. Enabled via `.withIndexHints(dir?)`.
29+
- **`endpoint-never-called`** (`info`) — `RouteTracker` accepts a list of registered
30+
routes (extracted by a regex scanner from Express/Fastify source files) and records
31+
inbound request hits via `createMiddleware()`. After the warmup period (default 5 min)
32+
a `'anomaly'` event is emitted listing routes that never received a request. Enabled
33+
via `.withRouteTracking(dir?, warmupMs?)`. Wired automatically in `profile-factory.ts`
34+
for `web` app type in dev/test environments.
35+
36+
- **Demo app — Phase R scenarios** (`quotes-demo-app/`):
37+
- W3C trace context is now propagated into every request via `agent.createMiddleware()`
38+
wired in `app.js` so all cross-signal rules can correlate DB calls with their
39+
originating HTTP request.
40+
- `GET /debug/correlated-slow` — runs 6 N+1 queries + 1 100 ms deliberate delay within
41+
one request, triggering `correlated-slow-endpoint` (critical).
42+
- `GET /debug/n-plus-one-in-txn``BEGIN` + 6 identical-template SELECTs + `COMMIT`,
43+
triggering `n-plus-one-in-transaction` (critical).
44+
- `GET /debug/sync-read` already demonstrates `sync-in-hot-path` now that the middleware
45+
is active (both `synchronous-fs` and `sync-in-hot-path` fire simultaneously).
46+
- The `anomaly` event handler in `diagnostic.js` now prints the full `suggestions` array
47+
(rule name, severity, message, suggestedFix) for all Phase R compound events.
48+
- `traffic.js` updated with three new Phase R traffic scenarios.
49+
1350
- **Phase R Wave 1 — Cross-signal diagnostic rules**: Five new rules that correlate events
1451
from multiple subsystems to produce high-signal compound anomalies that no single monitor
1552
can produce alone.

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,14 @@ cd quotes-demo-app && docker compose -f docker-compose-pg-only.yml up -d && npm
188188
node simulate.js # scripted traffic sequence — watch the agent fire in real time
189189
```
190190

191+
The scripted traffic sequence exercises every monitoring feature, including the Phase R cross-signal rules:
192+
193+
| Route | Phase R rule triggered | Severity |
194+
|---|---|---|
195+
| `GET /debug/sync-read` (×3, with `createMiddleware`) | `sync-in-hot-path` — sync FS inside live request | critical |
196+
| `GET /debug/correlated-slow` | `correlated-slow-endpoint` — N+1 + slow HTTP on same traceId | critical |
197+
| `GET /debug/n-plus-one-in-txn` | `n-plus-one-in-transaction` — N+1 inside BEGIN/COMMIT | critical |
198+
191199
See [`quotes-demo-app/README.md`](quotes-demo-app/README.md) for the full setup guide, annotated terminal output, and curl examples.
192200

193201
---
@@ -719,8 +727,12 @@ packages/agent/
719727
http-analyzer.ts → Insecure URL & slow request detection
720728
log-analyzer.ts → Log storm & payload size detection
721729
circuit-breaker-detector.ts → Sustained error-rate detection across drivers
722-
static-scanner.ts → Background tsc / ESLint issue tracking
730+
static-scanner.ts → Background tsc / ESLint / query-in-loop static analysis
723731
audit-scanner.ts → npm audit CVE scanning
732+
source-analyzer.ts → R.4: TypeScript AST scan for DB calls inside loops
733+
migration-scanner.ts → R.5: SQL & Prisma migration parser → index map
734+
index-hint-analyzer.ts → R.5: Runtime high-frequency missing-index detection
735+
route-tracker.ts → R.6: Endpoint-never-called detection after warmup
724736
725737
export/
726738
aggregator.ts → p99 sliding window metric aggregation
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type { FixSuggestion } from "./types.ts";
2+
3+
export interface IndexHintOptions {
4+
/** Minimum query executions per window before checking for missing indexes. Default: 100. */
5+
frequencyThreshold?: number;
6+
/** Sliding window duration in ms. Default: 60 000. */
7+
windowMs?: number;
8+
}
9+
10+
// Extracts the table name from the FROM clause of a sanitized SQL query.
11+
const FROM_RE = /\bFROM\s+["'`]?(\w+)["'`]?/i;
12+
13+
// Extracts column names from WHERE / AND / OR conditions.
14+
const WHERE_COL_RE =
15+
/(?:\bWHERE\b|\bAND\b|\bOR\b)\s+["'`]?(\w+)["'`]?\s*(?:=|>|<|>=|<=|<>|!=|LIKE|IN\s*\()/gi;
16+
17+
function extractTable(query: string): string | undefined {
18+
return FROM_RE.exec(query)?.[1];
19+
}
20+
21+
function extractWhereCols(query: string): string[] {
22+
const cols: string[] = [];
23+
WHERE_COL_RE.lastIndex = 0;
24+
let m: RegExpExecArray | null;
25+
while ((m = WHERE_COL_RE.exec(query)) !== null) {
26+
cols.push(m[1]);
27+
}
28+
return cols;
29+
}
30+
31+
/**
32+
* Tracks query execution frequency and emits missing-index-hint suggestions
33+
* when a high-frequency query targets un-indexed columns.
34+
*
35+
* Only fires when the caller has provided a non-empty index map (from MigrationScanner).
36+
* With an empty map there is no ground truth, so the rule stays silent to avoid
37+
* false positives.
38+
*/
39+
export class IndexHintAnalyzer {
40+
private readonly frequencyThreshold: number;
41+
private readonly windowMs: number;
42+
private readonly freq = new Map<string, { count: number; windowStart: number }>();
43+
private readonly indexMap: Map<string, Set<string>>;
44+
45+
constructor(indexMap: Map<string, Set<string>>, opts: IndexHintOptions = {}) {
46+
this.indexMap = indexMap;
47+
this.frequencyThreshold = opts.frequencyThreshold ?? 100;
48+
this.windowMs = opts.windowMs ?? 60_000;
49+
}
50+
51+
analyze(sanitizedQuery: string): FixSuggestion[] {
52+
// No ground truth — stay silent
53+
if (this.indexMap.size === 0) return [];
54+
55+
const key = sanitizedQuery.slice(0, 200);
56+
const now = Date.now();
57+
58+
const entry = this.freq.get(key);
59+
if (!entry || now - entry.windowStart > this.windowMs) {
60+
// Start a fresh window
61+
this.freq.set(key, { count: 1, windowStart: now });
62+
return [];
63+
}
64+
65+
entry.count++;
66+
if (entry.count < this.frequencyThreshold) return [];
67+
68+
// Threshold reached — check for missing indexes
69+
const table = extractTable(sanitizedQuery);
70+
if (!table) return [];
71+
72+
const indexedCols = this.indexMap.get(table);
73+
// Table not found in migration files — no ground truth, stay silent
74+
if (!indexedCols) return [];
75+
76+
const whereCols = extractWhereCols(sanitizedQuery);
77+
const missing = whereCols.filter((c) => !indexedCols.has(c));
78+
if (missing.length === 0) return [];
79+
80+
return missing.map((col) => ({
81+
severity: "warning" as const,
82+
rule: "missing-index-hint",
83+
message: `Query on ${table}.${col} fired ${entry.count}x in ${this.windowMs}ms but column has no index.`,
84+
suggestedFix: `CREATE INDEX idx_${table.toLowerCase()}_${col} ON ${table} (${col});`,
85+
}));
86+
}
87+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { readdir, readFile, stat } from "node:fs/promises";
2+
import { join, extname } from "node:path";
3+
4+
// Matches: CREATE [UNIQUE] INDEX [IF NOT EXISTS] <name> ON <table> (<col1>, <col2>, ...)
5+
const SQL_INDEX_RE =
6+
/CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?\S+\s+ON\s+["'`]?(\w+)["'`]?\s*\(([^)]+)\)/gi;
7+
8+
// Matches Prisma: @@index([col1, col2]) or @@unique([col1])
9+
const PRISMA_INDEX_RE = /@@(?:index|unique)\(\[([^\]]+)\]\)/gi;
10+
11+
// Matches the current Prisma model name: model <Name> {
12+
const PRISMA_MODEL_RE = /^model\s+(\w+)\s*\{/m;
13+
14+
async function collectFiles(dir: string, ext: string, found: string[]): Promise<void> {
15+
let entries: string[];
16+
try {
17+
const raw = await readdir(dir);
18+
entries = raw.map(String);
19+
} catch {
20+
return;
21+
}
22+
for (const entry of entries) {
23+
const full = join(dir, String(entry));
24+
try {
25+
const s = await stat(full);
26+
if (s.isDirectory()) {
27+
await collectFiles(full, ext, found);
28+
} else if (extname(String(entry)) === ext) {
29+
found.push(full);
30+
}
31+
} catch {
32+
// skip unreadable entries
33+
}
34+
}
35+
}
36+
37+
function parseSqlIndexes(sql: string, indexMap: Map<string, Set<string>>): void {
38+
SQL_INDEX_RE.lastIndex = 0;
39+
let m: RegExpExecArray | null;
40+
while ((m = SQL_INDEX_RE.exec(sql)) !== null) {
41+
const table = m[1];
42+
const cols = m[2].split(",").map((c) => c.trim().replace(/["'`]/g, ""));
43+
let set = indexMap.get(table);
44+
if (!set) {
45+
set = new Set();
46+
indexMap.set(table, set);
47+
}
48+
for (const col of cols) set.add(col);
49+
}
50+
}
51+
52+
function parsePrismaIndexes(content: string, indexMap: Map<string, Set<string>>): void {
53+
// Split into model blocks and extract model name + @@index directives
54+
const modelBlocks = content.split(/(?=\nmodel\s)/);
55+
for (const block of modelBlocks) {
56+
const modelMatch = PRISMA_MODEL_RE.exec(block);
57+
if (!modelMatch) continue;
58+
const modelName = modelMatch[1];
59+
60+
PRISMA_INDEX_RE.lastIndex = 0;
61+
let m: RegExpExecArray | null;
62+
while ((m = PRISMA_INDEX_RE.exec(block)) !== null) {
63+
const cols = m[1].split(",").map((c) => c.trim());
64+
let set = indexMap.get(modelName);
65+
if (!set) {
66+
set = new Set();
67+
indexMap.set(modelName, set);
68+
}
69+
for (const col of cols) set.add(col);
70+
}
71+
}
72+
}
73+
74+
/**
75+
* Scans migration files (SQL, Prisma schema) and returns a map of
76+
* table/model names to their indexed column sets.
77+
*/
78+
export class MigrationScanner {
79+
async scan(dir: string): Promise<Map<string, Set<string>>> {
80+
const indexMap = new Map<string, Set<string>>();
81+
82+
const sqlFiles: string[] = [];
83+
const prismaFiles: string[] = [];
84+
85+
await Promise.all([
86+
collectFiles(dir, ".sql", sqlFiles),
87+
collectFiles(dir, ".prisma", prismaFiles),
88+
]);
89+
90+
await Promise.all([
91+
...sqlFiles.map(async (f) => {
92+
try {
93+
const content = await readFile(f, "utf8");
94+
parseSqlIndexes(content, indexMap);
95+
} catch {
96+
// skip unreadable
97+
}
98+
}),
99+
...prismaFiles.map(async (f) => {
100+
try {
101+
const content = await readFile(f, "utf8");
102+
parsePrismaIndexes(content, indexMap);
103+
} catch {
104+
// skip unreadable
105+
}
106+
}),
107+
]);
108+
109+
return indexMap;
110+
}
111+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { FixSuggestion } from "./types.ts";
2+
3+
export interface RouteInfo {
4+
method: string;
5+
pattern: string;
6+
sourceLine: string;
7+
}
8+
9+
/**
10+
* Converts a route pattern (e.g. `/users/:id`) into a RegExp that matches
11+
* concrete URLs (e.g. `/users/123` or `/users/abc-def`).
12+
*/
13+
function patternToRegex(pattern: string): RegExp {
14+
const escaped = pattern
15+
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") // escape special chars
16+
.replace(/:\\[^/]+/g, "([^/]+)") // :param → ([^/]+) (after escaping colons become :\)
17+
.replace(/:\w+/g, "([^/]+)"); // :param → ([^/]+) (unescaped colons)
18+
return new RegExp(`^${escaped}/?$`, "i");
19+
}
20+
21+
function routeKey(method: string, pattern: string): string {
22+
return `${method.toUpperCase()} ${pattern}`;
23+
}
24+
25+
/**
26+
* R.6 — Tracks which registered routes have been called at least once.
27+
* After `check()` is called (e.g. after a warmup period), any route with zero
28+
* hits is reported as an `endpoint-never-called` suggestion.
29+
*/
30+
export class RouteTracker {
31+
private readonly hitKeys = new Set<string>();
32+
private readonly routes: RouteInfo[];
33+
private readonly routeRegexes: { key: string; regex: RegExp; info: RouteInfo }[];
34+
35+
constructor(knownRoutes: RouteInfo[]) {
36+
this.routes = knownRoutes;
37+
this.routeRegexes = knownRoutes.map((r) => ({
38+
key: routeKey(r.method, r.pattern),
39+
regex: patternToRegex(r.pattern),
40+
info: r,
41+
}));
42+
}
43+
44+
/**
45+
* Record that an inbound HTTP request hit a given method + URL.
46+
* Matches against all registered route patterns; parameterized routes
47+
* (e.g. `/users/:id`) match concrete URLs (e.g. `/users/123`).
48+
*/
49+
recordHit(method: string, url: string): void {
50+
const upperMethod = method.toUpperCase();
51+
// Strip query string for matching
52+
const path = url.split("?")[0];
53+
54+
for (const { key, regex, info } of this.routeRegexes) {
55+
if (info.method.toUpperCase() === upperMethod && regex.test(path)) {
56+
this.hitKeys.add(key);
57+
}
58+
}
59+
}
60+
61+
/**
62+
* Returns suggestions for routes that have never been hit.
63+
* Call this after the warmup period has elapsed.
64+
*/
65+
check(): FixSuggestion[] {
66+
return this.routes
67+
.filter((r) => !this.hitKeys.has(routeKey(r.method, r.pattern)))
68+
.map((r) => ({
69+
severity: "info" as const,
70+
rule: "endpoint-never-called",
71+
message: `${r.method.toUpperCase()} ${r.pattern} has never been called since startup.`,
72+
suggestedFix:
73+
"Consider removing this route if it is no longer needed, or add a health check / integration test that exercises it.",
74+
location: r.sourceLine,
75+
}));
76+
}
77+
}

0 commit comments

Comments
 (0)