{"version":3,"file":"query-findings.cjs","names":[],"sources":["../../src/findings/query-findings.ts"],"sourcesContent":["import type { LiveQueryOptimization } from \"../query.js\";\nimport type { PostgresExplainStage } from \"../sql/database.js\";\nimport type { Nudge } from \"../sql/nudges.js\";\n\n/**\n * Plan-aware query findings.\n *\n * The *anti-pattern* nudges (parseNudges in ./sql/nudges.ts) are AST-only: they\n * read the SQL string and flag syntactic patterns, never the EXPLAIN plan — so\n * they can say \"you put a function on a column\" but not \"…and that's why this\n * table is being sequentially scanned.\" (The nudge array can also carry\n * plan-derived *improvement* nudges — LARGE/SMALL_IMPROVEMENT_FOUND — but those\n * just report the optimizer's index gain as a number, not a plan-shape diagnosis.)\n *\n * This module fills that gap. Each finder is a pure function over data Query\n * Doctor already captures — the stored EXPLAIN plan plus the nudge array — and\n * emits a computed, human-readable verdict (\"X is slow because Y\"), not another\n * raw property for the UI to render. The output is deterministic: same plan in,\n * same findings out.\n */\n\nexport type QueryFindingSeverity = \"critical\" | \"warning\" | \"info\";\n\nexport type QueryFindingCode =\n  | \"COST_CONCENTRATION\"\n  | \"REPEATED_INNER_LOOP\"\n  | \"EXPENSIVE_SEQ_SCAN\"\n  | \"SORT_WITHOUT_INDEX\"\n  | \"WIDE_RESULT_NO_LIMIT\"\n  | \"FUNCTION_ON_COLUMN_BLOCKS_INDEX\"\n  | \"FULL_TABLE_SCAN\";\n\nexport interface QueryFinding {\n  code: QueryFindingCode;\n  severity: QueryFindingSeverity;\n  /** One-line headline, safe to show as a list item title. */\n  title: string;\n  /** The \"this is wrong because Y\" sentence, in plain language. Only states what\n   * the plan makes certain. */\n  detail: string;\n  /** An optional, explicitly-conditional next step — a *lead*, not a fix. Used\n   * when there's a plausible improvement we can't be sure of (it depends on the\n   * data), so it's hedged: \"often helps, verify before acting\". Prescriptions\n   * that we ARE sure of stay in `detail`. */\n  lead?: string;\n  /**\n   * Share of the query's cost this finding is worth fixing, 0–1. Mostly the\n   * node's own cost share — but for a loop that's already its multiplied weight,\n   * since Postgres folds the per-row repetition into the node's Total Cost; the\n   * function-on-column finding scores by the *multiplied* cost of the scans it\n   * blocks, and payload findings (not a cost share) get a size-derived score on\n   * the same scale. Findings are returned sorted by it, so a reader attacks the\n   * biggest lever first and isn't misled by a scary-but-tiny finding.\n   */\n  impact: number;\n  /** Structured backing for the claim — rendered as evidence chips. */\n  evidence?: Record<string, string | number>;\n}\n\n// Relevance floor. Findings are for queries worth optimising; below this total\n// cost the query is trivially fast even at the analyzer's predicted scale, so an\n// index or rewrite isn't worth a reader's attention — flagging it (e.g. \"an\n// index would help\" on a 6-row config-table scan) is just noise. Tunable; on the\n// dogfood payload it drops ~45% of finding-bearing queries, all genuinely fast.\nconst MIN_QUERY_COST = 100;\n// Access paths that already use an index. When one of these dominates the cost\n// and the optimizer found no better index, there's no lever — the cost is\n// inherent to the rows read, so \"target this node\" is empty advice, not a\n// finding. (A Seq Scan is excluded: it's the classic missing-index signal.)\nconst INDEX_SERVED_NODES = new Set([\n  \"Index Only Scan\",\n  \"Index Scan\",\n  \"Bitmap Heap Scan\",\n  \"Bitmap Index Scan\",\n]);\n// A single node contributing at least this share of the query's total cost is\n// worth calling out as where the time goes.\nconst COST_CONCENTRATION_SHARE = 0.5;\n// Above this share, the concentration is severe enough to warn rather than note.\nconst COST_CONCENTRATION_WARN_SHARE = 0.8;\n// A sequential scan is flagged when it carries this share of the cost on its own\n// or scans at least this many estimated rows — either makes it index-worthy.\nconst SEQ_SCAN_COST_SHARE = 0.3;\nconst SEQ_SCAN_ROWS = 50_000;\n// A node whose Total Cost dwarfs its children's single-pass cost by more than\n// this is re-executing a child per row — the no-EXPLAIN-ANALYZE tell of loop\n// multiplication. Pair it with a minimum share so only loops that actually\n// matter are flagged, and cap how many we report per query to avoid noise.\nconst LOOP_RATIO = 10;\nconst LOOP_SHARE = 0.25;\nconst MAX_LOOP_FINDINGS = 3;\n// A Sort node carrying at least this share of the query's cost is worth flagging\n// as removable with a matching index, rather than paid on every call.\nconst SORT_COST_SHARE = 0.2;\nconst SORT_WARN_SHARE = 0.4;\n// Estimated result payload (rows × width) that makes an unbounded query notable\n// now, and the point at which it warrants a warning rather than a note.\nconst WIDE_RESULT_BYTES = 100_000;\nconst WIDE_RESULT_WARN_BYTES = 256_000;\n// A node's cost is split into named components (disk I/O, per-row CPU, …). We\n// only name the dominant one when it carries at least this share of the node's\n// own cost — below it, no single component explains where the cost goes.\nconst COST_DRIVER_SHARE = 0.4;\n// A scan that reads many rows but returns very few is discarding nearly all its\n// work — the strongest missing-index signal. Being a ratio (output ÷ scanned) it\n// survives the analyzer's synthetic scaling: a table faked to 10M rows still\n// returns the same handful. Only stated when the scan is sizeable and the filter\n// genuinely selective, so it never fires on a small table or a broad scan.\nconst SELECTIVE_SCAN_MIN_SCANNED = 10_000;\nconst SELECTIVE_SCAN_MAX_RATIO = 0.05;\n\n/**\n * The plan is typed as a discriminated union whose variants only cover a handful\n * of node types; every other field (Plan Rows, Startup Cost, Sort/Nested Loop\n * nodes …) arrives via the schema's `.passthrough()`. So we read it as a loose\n * record and pull values defensively rather than fighting the union.\n */\ntype PlanNode = Record<string, unknown> & { Plans?: PlanNode[] };\n\nfunction asNode(plan: PostgresExplainStage): PlanNode {\n  return plan as unknown as PlanNode;\n}\n\nfunction num(node: PlanNode, key: string): number | undefined {\n  const value = node[key];\n  return typeof value === \"number\" ? value : undefined;\n}\n\nfunction str(node: PlanNode, key: string): string | undefined {\n  const value = node[key];\n  return typeof value === \"string\" ? value : undefined;\n}\n\nfunction childNodes(node: PlanNode): PlanNode[] {\n  return Array.isArray(node.Plans) ? node.Plans : [];\n}\n\nfunction walk(node: PlanNode, visit: (node: PlanNode) => void): void {\n  visit(node);\n  for (const child of childNodes(node)) walk(child, visit);\n}\n\n/**\n * Postgres reports cumulative Total Cost (a node's cost includes its children).\n * A node's own contribution is therefore its Total Cost minus its children's —\n * which telescopes so the self-costs across the tree sum back to the root total.\n */\nfunction selfCost(node: PlanNode): number {\n  const total = num(node, \"Total Cost\") ?? 0;\n  const childTotal = childNodes(node).reduce(\n    (sum, child) => sum + (num(child, \"Total Cost\") ?? 0),\n    0,\n  );\n  return Math.max(0, total - childTotal);\n}\n\nfunction nodeLabel(node: PlanNode): string {\n  const type = str(node, \"Node Type\") ?? \"node\";\n  const relation = str(node, \"Relation Name\") ?? str(node, \"Alias\");\n  return relation ? `${type} on ${relation}` : type;\n}\n\nfunction formatBytes(bytes: number): string {\n  if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;\n  if (bytes >= 1_000) return `${Math.round(bytes / 1_000)} KB`;\n  return `${Math.round(bytes)} B`;\n}\n\n/**\n * QD's augmented planner annotates each plan node with a `Cost Breakdown`: the\n * node's cost split into named components ({ Cost, Reason, Formula?, Variables? }),\n * each a real planner term — sequential disk I/O over relpages, per-row heap\n * filtering over reltuples, random index-page access, and so on. Vanilla Postgres\n * plans don't carry it, so it's absent for un-augmented optimizations.\n */\ninterface CostComponent {\n  cost: number;\n  reason: string;\n  variables: Record<string, unknown>;\n}\n\nfunction costBreakdown(node: PlanNode): CostComponent[] {\n  const raw = node[\"Cost Breakdown\"];\n  if (!Array.isArray(raw)) return [];\n  const components: CostComponent[] = [];\n  for (const entry of raw) {\n    if (typeof entry !== \"object\" || entry === null) continue;\n    const record = entry as Record<string, unknown>;\n    const cost = record[\"Cost\"];\n    const reason = record[\"Reason\"];\n    if (typeof cost !== \"number\" || typeof reason !== \"string\") continue;\n    const variables =\n      typeof record[\"Variables\"] === \"object\" && record[\"Variables\"] !== null\n        ? (record[\"Variables\"] as Record<string, unknown>)\n        : {};\n    components.push({ cost, reason, variables });\n  }\n  return components;\n}\n\n/** Plain-language name for a cost component, citing its load-bearing cardinality\n * (the planner's assumed relpages / reltuples). Maps only the Reasons QD's\n * planner actually emits; an unmapped one falls back to a generic phrase so a new\n * component never breaks the sentence. The distinction that matters to a reader\n * is disk I/O (the table or index is big on disk — fewer pages or a covering\n * index helps) vs per-row CPU (many rows flow through — better selectivity helps). */\nfunction costComponentPhrase(component: CostComponent): string {\n  const cardinality = (key: string): string | undefined => {\n    const value = component.variables[key];\n    return typeof value === \"number\"\n      ? value.toLocaleString(\"en-US\")\n      : undefined;\n  };\n  switch (component.reason) {\n    case \"RUNTIME:DISK_IO\": {\n      const pages = cardinality(\"relpages\");\n      return pages\n        ? `reading the table off disk (~${pages} pages)`\n        : \"sequential disk reads\";\n    }\n    case \"RUNTIME:DISK_ACCESS\": {\n      const pages = cardinality(\"index_pages_fetched\");\n      return pages\n        ? `random index-page reads (~${pages} pages)`\n        : \"random index-page reads\";\n    }\n    case \"RUNTIME:WORST_CASE_IO\": {\n      const pages = cardinality(\"pages_fetched\");\n      return pages\n        ? `worst-case random heap reads (~${pages} pages, assuming low page visibility)`\n        : \"worst-case random heap reads\";\n    }\n    case \"RUNTIME:HEAP_FETCH_AND_FILTER\": {\n      const rows = cardinality(\"reltuples\");\n      return rows\n        ? `fetching and filtering ~${rows} rows from the heap`\n        : \"per-row heap fetch and filter\";\n    }\n    case \"RUNTIME:HEAP_FILTER\": {\n      const rows = cardinality(\"reltuples\");\n      return rows ? `filtering ~${rows} rows` : \"per-row filtering\";\n    }\n    case \"RUNTIME:INDEX_FILTER\":\n      return \"scanning index entries\";\n    case \"RUNTIME:BITMAP\":\n      return \"building the row bitmap\";\n    default:\n      return \"its main cost component\";\n  }\n}\n\n/** A one-sentence \"where this node's cost goes\", derived from its Cost Breakdown:\n * the single largest positive component, in plain language. Discounts (negative\n * parallel-worker / I/O-correlation adjustments) and ~zero startup terms aren't\n * where the cost lands, so they're dropped. Returns \"\" when the node has no\n * breakdown (vanilla plan) or no component clearly dominates — callers append it\n * unconditionally and get nothing when there's nothing certain to add. */\nfunction whyExpensive(node: PlanNode): string {\n  const positive = costBreakdown(node).filter((c) => c.cost > 0);\n  if (positive.length === 0) return \"\";\n  const total = positive.reduce((sum, c) => sum + c.cost, 0);\n  const dominant = positive.reduce((a, b) => (b.cost > a.cost ? b : a));\n  if (total <= 0 || dominant.cost / total < COST_DRIVER_SHARE) return \"\";\n  return ` Most of that node's cost is ${costComponentPhrase(dominant)}.`;\n}\n\n/** Rows a scan node reads, from its Cost Breakdown's `reltuples` (the planner's\n * table-size estimate) — distinct from Plan Rows, which is the *post-filter*\n * output. Undefined when the node carries no breakdown cardinality (vanilla plan). */\nfunction rowsScanned(node: PlanNode): number | undefined {\n  let max: number | undefined;\n  for (const component of costBreakdown(node)) {\n    const value = component.variables[\"reltuples\"];\n    if (typeof value === \"number\" && (max === undefined || value > max)) {\n      max = value;\n    }\n  }\n  return max;\n}\n\n/** A diagnosis sentence for a scan that reads far more rows than it returns: the\n * filter throws away nearly everything it touched, which is exactly the work an\n * index avoids. Pure diagnosis — it states the waste, not a fix; the optimizer's\n * index verdict (handled by the caller) decides whether an index is the answer.\n * \"\" when the node isn't a selective scan or has no breakdown to measure it from. */\nfunction selectivityClause(node: PlanNode): string {\n  const scanned = rowsScanned(node);\n  const returned = num(node, \"Plan Rows\");\n  if (scanned === undefined || returned === undefined) return \"\";\n  if (scanned < SELECTIVE_SCAN_MIN_SCANNED) return \"\";\n  const ratio = scanned > 0 ? returned / scanned : 1;\n  if (ratio >= SELECTIVE_SCAN_MAX_RATIO) return \"\";\n  const pct = ratio < 0.0001 ? \"<0.01%\" : `~${(ratio * 100).toFixed(2)}%`;\n  return ` It scans ~${scanned.toLocaleString(\n    \"en-US\",\n  )} rows and returns ~${returned.toLocaleString(\n    \"en-US\",\n  )} (${pct}), so nearly all of that work is rows the filter discards.`;\n}\n\n/** A cost as a fraction of the query total, clamped to [0,1]. Parallel plans\n * (Gather / Gather Merge) report a child node's cost as the per-worker figure —\n * which can exceed the gathered root total, since the Gather divides the work.\n * Left unclamped that yields a nonsensical >100% share. */\nfunction clampShare(cost: number, total: number): number {\n  if (total <= 0) return 0;\n  return Math.min(1, cost / total);\n}\n\n/** A node that reads an entire relation with nothing to narrow it: a Seq Scan or\n * full Index Only Scan carrying no Filter / Index Cond / Recheck Cond. The cost\n * is the whole table by definition — and since there's no predicate, no index can\n * avoid the pass (which is why the optimizer returns no_improvement_found). */\nfunction isUnfilteredFullRead(node: PlanNode): boolean {\n  const type = str(node, \"Node Type\");\n  if (type !== \"Seq Scan\" && type !== \"Index Only Scan\") return false;\n  return (\n    node[\"Filter\"] == null &&\n    node[\"Index Cond\"] == null &&\n    node[\"Recheck Cond\"] == null\n  );\n}\n\n/** Whether the plan aggregates, and how — a scalar aggregate (count(*) over the\n * whole table, no grouping) can be approximated or maintained out of band, a\n * grouped one (GROUP BY / HAVING) genuinely needs every row, and neither rules\n * the full read. Grouped wins when both appear (e.g. `count(*)` over a grouped\n * subquery), since the grouping is what forces the whole-table pass. */\nfunction aggregateShape(root: PlanNode): \"scalar\" | \"grouped\" | \"none\" {\n  let scalar = false;\n  let grouped = false;\n  walk(root, (node) => {\n    if (str(node, \"Node Type\") !== \"Aggregate\") return;\n    const groupKey = node[\"Group Key\"];\n    if (Array.isArray(groupKey) && groupKey.length > 0) grouped = true;\n    else scalar = true;\n  });\n  return grouped ? \"grouped\" : scalar ? \"scalar\" : \"none\";\n}\n\n/** FULL_TABLE_SCAN: an unfiltered whole-relation read that dominates the cost\n * while the optimizer found no index that helps — because there's no predicate to\n * index. Without this the query shows *zero* findings (the dominant node is\n * index-served, so the concentration pass suppresses it), which reads as \"QD\n * didn't look\". This fills that silence with the reason and an honest lead. */\nfunction buildFullTableReadFinding(\n  node: PlanNode,\n  share: number,\n  shape: \"scalar\" | \"grouped\" | \"none\",\n): QueryFinding {\n  const pct = Math.round(share * 100);\n  const relation = relationOf(node);\n  const target = relation ? `\\`${relation}\\`` : \"The table\";\n  let detail: string;\n  let lead: string | undefined;\n  if (shape === \"scalar\") {\n    detail = `${target} is read end to end to compute the aggregate — there's no filter to narrow it, so no index avoids the full pass (the optimizer confirmed none helps).`;\n    lead = `If this runs often, an approximate count (\\`pg_class.reltuples\\`) or a maintained tally avoids re-reading the whole table each call.`;\n  } else if (shape === \"grouped\") {\n    detail = `${target} is read end to end to group every row — there's no filter to narrow it, so the whole-table pass is inherent to the query.`;\n  } else {\n    detail = `${target} is read end to end — there's no filter to narrow it, so no index avoids the full pass (the optimizer confirmed none helps).`;\n    lead = `If you don't need every row, a WHERE filter or LIMIT lets an index read only part of the table.`;\n  }\n  return {\n    code: \"FULL_TABLE_SCAN\",\n    severity: \"info\",\n    impact: share,\n    title: relation ? `Whole-table read of ${relation}` : \"Whole-table read\",\n    detail: `${detail}${whyExpensive(node)}`,\n    ...(lead ? { lead } : {}),\n    evidence: {\n      ...(relation ? { relation } : {}),\n      costShare: `${pct}%`,\n    },\n  };\n}\n\n/** Sort nodes a LIMIT bounds directly (top-N) — `Limit → Sort`, possibly through\n * pass-through nodes (Result / Gather Merge / Gather). For these the win from a\n * matching index is bigger than skipping the sort: the LIMIT also lets the scan\n * stop after the first n rows instead of sorting the whole set. A Limit above an\n * Aggregate or a Join doesn't bound the sort below it, so the descent stops at\n * any non-pass-through node. */\nconst LIMIT_PASSTHROUGH = new Set([\n  \"Result\",\n  \"Gather\",\n  \"Gather Merge\",\n  \"LockRows\",\n]);\nfunction sortsBoundedByLimit(root: PlanNode): Set<PlanNode> {\n  const bounded = new Set<PlanNode>();\n  walk(root, (node) => {\n    if (str(node, \"Node Type\") !== \"Limit\") return;\n    let cursor: PlanNode | undefined = node;\n    const seen = new Set<PlanNode>();\n    while (cursor && !seen.has(cursor)) {\n      seen.add(cursor);\n      const child = childNodes(cursor)[0];\n      if (!child) break;\n      const type = str(child, \"Node Type\");\n      if (type === \"Sort\") {\n        bounded.add(child);\n        break;\n      }\n      if (!LIMIT_PASSTHROUGH.has(type ?? \"\")) break;\n      cursor = child;\n    }\n  });\n  return bounded;\n}\n\n/** Advice for a dominant Sort node. Seq Scan index advice keys off the\n * optimizer's verdict (handled inline); a Nested Loop is a multiplication (its\n * own finding); everything else has no generic hint — hence Sort-only here. */\nfunction sortHint(nodeType: string | undefined): string {\n  return nodeType === \"Sort\"\n    ? \" It's an in-memory sort — an index matching the ORDER BY could avoid it.\"\n    : \"\";\n}\n\nfunction relationOf(node: PlanNode): string | undefined {\n  return str(node, \"Relation Name\") ?? str(node, \"Alias\");\n}\n\n/** A nested loop's two inputs: the outer side (driven once, its row count is the\n * loop count) and the inner side (re-run once per outer row). Postgres tags them\n * via Parent Relationship; fall back to child order when the tag is absent. */\nfunction loopSides(node: PlanNode): { outer?: PlanNode; inner?: PlanNode } {\n  const kids = childNodes(node);\n  const outer =\n    kids.find((k) => str(k, \"Parent Relationship\") === \"Outer\") ?? kids[0];\n  const inner =\n    kids.find((k) => str(k, \"Parent Relationship\") === \"Inner\") ?? kids[1];\n  return { outer, inner };\n}\n\n// Honest, conditional lead (not a prescription): per-row correlated subqueries\n// can often be restructured set-based, but whether it's faster depends on the\n// data, so it's explicitly hedged. Names the subquery's table when we know it.\nfunction setBasedLead(relation?: string): string {\n  const target = relation ? `\\`${relation}\\`` : \"the subquery's table\";\n  return `This subquery might fold into one set-based pass: aggregate or join ${target} once, then filter. Often much faster, but it depends on the data, so check before committing.`;\n}\n\n/** A node's SubPlan children — correlated subqueries (EXISTS, scalar) run once\n * per row, whether in the Filter (EXISTS) or the SELECT list (a scalar COUNT).\n * Parent Relationship \"SubPlan\" is the reliable tell; an InitPlan (also carries\n * a Subplan Name) runs once, so it's deliberately excluded. */\nfunction subplanChildren(node: PlanNode): PlanNode[] {\n  return childNodes(node).filter(isSubplanChild);\n}\n\n/** REPEATED_INNER_LOOP for the SubPlan-on-a-node shape: a Seq/Index Scan (or\n * other node) that evaluates correlated subqueries once per row. The planner\n * folds that multiplied cost into the node's own total, so a naive reading\n * blames the scan and says \"add an index\" — but the cost is the per-row\n * repetition, not the scan. Covers both the d2armory count(*) (EXISTS subplans\n * in the Filter) and the migration backfill (scalar subplans in the SELECT). */\nfunction buildSubplanFinding(\n  node: PlanNode,\n  self: number,\n  rootTotal: number,\n): QueryFinding {\n  const share = clampShare(self, rootTotal);\n  const pct = Math.round(share * 100);\n  const relation = relationOf(node);\n  const nodeType = str(node, \"Node Type\") ?? \"scan\";\n  const subplans = subplanChildren(node);\n  const loops = num(node, \"Plan Rows\");\n  const loopsText =\n    loops !== undefined ? ` (~${loops.toLocaleString(\"en-US\")} rows)` : \"\";\n  const count = subplans.length;\n  const noun =\n    count === 1 ? \"a correlated subquery\" : `${count} correlated subqueries`;\n  const indexClause = relation\n    ? ` An index on \\`${relation}\\` can shrink the scan but not the per-row subqueries.`\n    : ``;\n  // The subquery's own driving table — the thing a set-based rewrite aggregates.\n  let subqueryRelation: string | undefined;\n  for (const subplan of subplans) {\n    const driver = heaviestDriver(subplan);\n    const rel = driver ? relationOf(driver) : undefined;\n    if (rel) {\n      subqueryRelation = rel;\n      break;\n    }\n  }\n  return {\n    code: \"REPEATED_INNER_LOOP\",\n    severity: share >= COST_CONCENTRATION_WARN_SHARE ? \"warning\" : \"info\",\n    impact: share,\n    title: \"Correlated subquery runs once per row\",\n    detail: `This ${nodeType}${\n      relation ? ` over \\`${relation}\\`` : \"\"\n    } runs ${noun} once per row${loopsText}, so about ${pct}% of the cost is that repetition, not the scan.${indexClause}`,\n    lead: setBasedLead(subqueryRelation),\n    evidence: {\n      ...(relation ? { relation } : {}),\n      ...(loops !== undefined ? { loops } : {}),\n      subqueries: count,\n      costShare: `${pct}%`,\n    },\n  };\n}\n\n/** The inner side is a correlated subquery (re-evaluated per outer row) rather\n * than a plain table probe — the tell is a Memoize/Subquery/SubPlan in its\n * subtree. Postgres only inserts Memoize to cache a repeatedly-run inner, so its\n * presence is itself the signal that work is being multiplied per row. */\nfunction innerIsCorrelatedSubquery(inner: PlanNode): boolean {\n  let found = false;\n  walk(inner, (n) => {\n    const type = str(n, \"Node Type\");\n    if (type === \"Memoize\" || type === \"Subquery Scan\") found = true;\n    if (str(n, \"Parent Relationship\") === \"SubPlan\") found = true;\n    if (typeof n[\"Subplan Name\"] === \"string\") found = true;\n  });\n  return found;\n}\n\n/** The single heaviest node within a subtree (by own cost), used to name what\n * actually drives an inner side's per-loop cost. */\nfunction heaviestDriver(subtree: PlanNode): PlanNode | undefined {\n  let best: PlanNode | undefined;\n  let bestSelf = -1;\n  walk(subtree, (n) => {\n    const s = selfCost(n);\n    if (s > bestSelf) {\n      bestSelf = s;\n      best = n;\n    }\n  });\n  return best;\n}\n\n/** True when a child node is a correlated SubPlan (e.g. a scalar COUNT subquery\n * in the SELECT list), which re-runs per row of its parent rather than once. */\nfunction isSubplanChild(node: PlanNode): boolean {\n  return str(node, \"Parent Relationship\") === \"SubPlan\";\n}\n\n/**\n * The node whose *multiplied* cost (own cost × how many times it runs) is\n * largest within a loop's per-row work — i.e. where the repeated cost truly\n * lands. A node cheap per run (a 631-cost heap fetch, an 8-cost COUNT) can\n * dominate once run thousands of times; a plain \"heaviest node\" reading misses\n * it. Per-row work hangs off the loop two ways: the inner side (run per outer\n * row) and correlated SubPlan children (run per *output* row, e.g. scalar\n * subqueries in the SELECT). Both are walked. No-EXPLAIN-ANALYZE: executions\n * come from Plan Rows × nested-loop / subplan multipliers, never real loops.\n */\nfunction multipliedDriver(\n  loopNode: PlanNode,\n  baseLoops: number,\n): { node: PlanNode; multiplied: number } | undefined {\n  const { inner } = loopSides(loopNode);\n  const subplans = subplanChildren(loopNode);\n  const outputRows = num(loopNode, \"Plan Rows\") ?? baseLoops;\n\n  let best: PlanNode | undefined;\n  let bestCost = -1;\n  const visit = (node: PlanNode, executions: number) => {\n    const cost = selfCost(node) * executions;\n    if (cost > bestCost) {\n      bestCost = cost;\n      best = node;\n    }\n    let innerChild: PlanNode | undefined;\n    let outerRows = 1;\n    if (str(node, \"Node Type\") === \"Nested Loop\") {\n      const sides = loopSides(node);\n      innerChild = sides.inner;\n      outerRows = sides.outer ? (num(sides.outer, \"Plan Rows\") ?? 1) : 1;\n    }\n    const rows = num(node, \"Plan Rows\") ?? 1;\n    for (const child of childNodes(node)) {\n      const childExecutions =\n        child === innerChild\n          ? executions * outerRows\n          : isSubplanChild(child)\n            ? executions * rows\n            : executions;\n      visit(child, childExecutions);\n    }\n  };\n  if (inner) visit(inner, baseLoops);\n  for (const subplan of subplans) visit(subplan, outputRows);\n  return best ? { node: best, multiplied: bestCost } : undefined;\n}\n\n/** Ratio of a node's Total Cost to its children's combined single-pass cost.\n * >LOOP_RATIO means the node re-runs a child per row (loop multiplication). */\nfunction loopRatio(node: PlanNode): number {\n  const childTotal = childNodes(node).reduce(\n    (sum, child) => sum + (num(child, \"Total Cost\") ?? 0),\n    0,\n  );\n  const total = num(node, \"Total Cost\") ?? 0;\n  return childTotal > 0 ? total / childTotal : 0;\n}\n\n/** Identity of what a nested loop repeats, so twin loops across subplans (same\n * correlated inner) collapse to one finding instead of several. */\nfunction loopSignature(node: PlanNode): string {\n  const { inner } = loopSides(node);\n  const driver = inner ? heaviestDriver(inner) : undefined;\n  const driverRel = driver ? relationOf(driver) : undefined;\n  const correlated = inner ? innerIsCorrelatedSubquery(inner) : false;\n  return `${driverRel ?? \"?\"}|${correlated}`;\n}\n\n/** Build the REPEATED_INNER_LOOP verdict for a nested loop. Diagnose, don't\n * prescribe: decompose the cost into loops × per-loop, name what drives the\n * per-loop cost, and — when the inner is a correlated subquery — state the\n * load-bearing fact that an index can't remove the per-row repetition. No\n * rewrite advice: whether a rewrite helps is conditional and a human's call. */\nfunction buildLoopFinding(\n  node: PlanNode,\n  self: number,\n  rootTotal: number,\n): QueryFinding {\n  const share = clampShare(self, rootTotal);\n  const pct = Math.round(share * 100);\n  const { outer, inner } = loopSides(node);\n  const loops = outer ? num(outer, \"Plan Rows\") : undefined;\n  const loopsText =\n    loops !== undefined ? `~${loops.toLocaleString(\"en-US\")}` : \"many\";\n  // Correlated either way: the inner side is a subquery, OR the loop carries\n  // correlated SubPlan children (scalar subqueries in the SELECT that re-run\n  // per row) — the case the GET /projects list hit.\n  const correlated =\n    (inner ? innerIsCorrelatedSubquery(inner) : false) ||\n    childNodes(node).some(isSubplanChild);\n\n  // Where the repeated cost truly lands — the node that's cheap per run but\n  // dominant once multiplied by the loop count. This is the actionable target;\n  // the loop itself is just the accumulator.\n  const driver =\n    loops !== undefined ? multipliedDriver(node, loops) : undefined;\n  const driverNode = driver?.node;\n  const driverRel = driverNode ? relationOf(driverNode) : undefined;\n  const driverType = driverNode ? str(driverNode, \"Node Type\") : undefined;\n  const driverShare = driver\n    ? clampShare(driver.multiplied, rootTotal)\n    : undefined;\n  const driverPct =\n    driverShare !== undefined ? Math.round(driverShare * 100) : undefined;\n\n  const driverText =\n    driverRel && driverType && driverPct !== undefined\n      ? ` Most of it is the ${driverType} on \\`${driverRel}\\`, run ${loopsText} times (about ${driverPct}% of the query).`\n      : ``;\n  // The repetition is structural for a correlated subquery: it re-runs per row\n  // no matter the access path. Whether the driver above is even index-improvable\n  // is node-specific (a heap fetch of wide rows isn't), so we don't claim it —\n  // we state only the load-bearing, unconditional fact.\n  const structural = correlated\n    ? ` It's a correlated subquery, so an index can speed each run but won't cut the repeats.`\n    : ``;\n\n  return {\n    code: \"REPEATED_INNER_LOOP\",\n    severity: share >= COST_CONCENTRATION_WARN_SHARE ? \"warning\" : \"info\",\n    impact: share,\n    title: correlated\n      ? \"Correlated subquery runs once per row\"\n      : \"Inner side re-runs once per row\",\n    detail: `A nested loop re-runs its inner side once per row (${loopsText} rows), so about ${pct}% of the cost is that repetition, not one node.${driverText}${structural}`,\n    ...(correlated ? { lead: setBasedLead(driverRel) } : {}),\n    evidence: {\n      ...(loops !== undefined ? { loops } : {}),\n      ...(correlated ? { innerKind: \"correlated subquery\" } : {}),\n      ...(driverRel ? { driver: driverRel } : {}),\n      ...(driverPct !== undefined ? { driverShare: `${driverPct}%` } : {}),\n      costShare: `${pct}%`,\n    },\n  };\n}\n\nfunction escapeRegExp(value: string): string {\n  return value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\n// Plan node fields that carry a condition expression as text.\nconst CONDITION_KEYS = [\n  \"Hash Cond\",\n  \"Join Filter\",\n  \"Filter\",\n  \"Recheck Cond\",\n  \"Merge Cond\",\n  \"Index Cond\",\n] as const;\n\n/**\n * Relations whose columns are wrapped in a function (or cast) inside some plan\n * condition — i.e. the relations a function-on-column actually blocks an index\n * on. The function-on-column nudge is AST-only and doesn't know which table is\n * affected; reading it off the plan's condition strings (e.g. `lower(cfg.repo)`,\n * mapping the alias back to its relation) is what stops us from blaming an\n * unrelated table that just happens to be sequentially scanned.\n */\nfunction relationsWrappedInFunction(root: PlanNode): Set<string> {\n  const conditions: string[] = [];\n  const aliasToRelation = new Map<string, string>();\n  walk(root, (node) => {\n    for (const key of CONDITION_KEYS) {\n      const value = node[key];\n      if (typeof value === \"string\") conditions.push(value);\n    }\n    const relation = str(node, \"Relation Name\");\n    if (relation) {\n      aliasToRelation.set(relation, relation);\n      const alias = str(node, \"Alias\");\n      if (alias) aliasToRelation.set(alias, relation);\n    }\n  });\n\n  const wrapped = new Set<string>();\n  for (const [aliasOrName, relation] of aliasToRelation) {\n    const ref = escapeRegExp(aliasOrName);\n    // A function call whose argument list (no nested parens) contains `ref.col`,\n    // or a cast of `ref.col` — both forms the planner prints for the pattern.\n    const inFunction = new RegExp(`[a-z_][a-z0-9_]*\\\\([^()]*\\\\b${ref}\\\\.`, \"i\");\n    const inCast = new RegExp(`\\\\b${ref}\\\\.[a-z_][a-z0-9_]*\\\\s*::`, \"i\");\n    if (conditions.some((c) => inFunction.test(c) || inCast.test(c))) {\n      wrapped.add(relation);\n    }\n  }\n  return wrapped;\n}\n\nfunction planOf(\n  optimization: LiveQueryOptimization | undefined,\n): PostgresExplainStage | undefined {\n  if (!optimization) return undefined;\n  if (\n    optimization.state === \"improvements_available\" ||\n    optimization.state === \"no_improvement_found\"\n  ) {\n    return optimization.explainPlan;\n  }\n  return undefined;\n}\n\n/**\n * Compute the plan-aware findings for one query from its stored optimization and\n * nudges. Returns [] when there's no plan to reason over (waiting, optimizing,\n * not_supported, timeout, error) — the syntactic nudges still stand on their own.\n */\nexport function analyzeQueryFindings(\n  optimization: LiveQueryOptimization | undefined,\n  nudges: Nudge[] = [],\n): QueryFinding[] {\n  const plan = planOf(optimization);\n  if (!plan) return [];\n\n  const root = asNode(plan);\n  const rootTotal = num(root, \"Total Cost\") ?? 0;\n  // Skip queries too cheap to be worth optimising — see MIN_QUERY_COST.\n  if (rootTotal < MIN_QUERY_COST) return [];\n\n  // The optimizer's own index verdict — the one thing we're *certain* of, since\n  // the recommender already ran the planner with and without candidate indexes.\n  // A plan only exists for improvements_available / no_improvement_found, so an\n  // index-related finding is always one of these two. Prefer naming the proven\n  // index; never tell an agent to add one the optimizer already searched for and\n  // didn't find.\n  const recommendedDefs =\n    optimization?.state === \"improvements_available\"\n      ? optimization.indexRecommendations\n          .map((rec) => rec.definition)\n          .filter((def): def is string => typeof def === \"string\" && def !== \"\")\n      : [];\n  const recommendedClause =\n    recommendedDefs.length > 0\n      ? ` The recommended index ${recommendedDefs\n          .map((def) => `\\`${def}\\``)\n          .join(\" / \")} would help this.`\n      : \"\";\n  // Index advice for a scan finding: name the proven index, or — when the\n  // optimizer searched and found none — say so factually (it doesn't mean *no*\n  // index can ever help; the candidate set is limited), rather than the generic\n  // \"add an index\" that would contradict the optimizer.\n  const scanIndexClause =\n    recommendedClause ||\n    (optimization?.state === \"no_improvement_found\"\n      ? \" The optimizer checked for an index and found none that helps here.\"\n      : \" An index on the filtered or joined columns would likely remove it.\");\n\n  const nodes: { node: PlanNode; self: number }[] = [];\n  walk(root, (node) => nodes.push({ node, self: selfCost(node) }));\n\n  // How many times each node runs (no EXPLAIN ANALYZE): 1, multiplied by every\n  // ancestor nested loop's outer-row count on its inner side. Lets findings rank\n  // by *multiplied* cost — a cheap node looped thousands of times outweighs an\n  // expensive one run once.\n  const executions = new Map<PlanNode, number>();\n  const countExecutions = (node: PlanNode, runs: number) => {\n    executions.set(node, runs);\n    let innerChild: PlanNode | undefined;\n    let outerRows = 1;\n    if (str(node, \"Node Type\") === \"Nested Loop\") {\n      const sides = loopSides(node);\n      innerChild = sides.inner;\n      outerRows = sides.outer ? (num(sides.outer, \"Plan Rows\") ?? 1) : 1;\n    }\n    for (const child of childNodes(node)) {\n      countExecutions(child, child === innerChild ? runs * outerRows : runs);\n    }\n  };\n  countExecutions(root, 1);\n  const multipliedCost = (node: PlanNode) =>\n    selfCost(node) * (executions.get(node) ?? 1);\n\n  const findings: QueryFinding[] = [];\n\n  // Nodes already explained by a finding below, so the seq-scan / sort passes\n  // don't report the same node twice.\n  const reported = new Set<PlanNode>();\n\n  // 1a. Loop multiplication, anywhere in the tree. A nested loop's cost is\n  // outer_rows × inner_cost, not work the node does — so report it as that\n  // decomposition. A node is a genuine multiplier when its cost dwarfs its\n  // children's single-pass cost (ratio > LOOP_RATIO): the no-EXPLAIN-ANALYZE\n  // tell that the inner re-runs per outer row. Walking the whole tree (not just\n  // the single worst node) catches an inner that's cheap alone but expensive\n  // because it loops — the case a \"where's the cost\" headline misses.\n  if (rootTotal > 0) {\n    const loopNodes = nodes\n      .filter(\n        ({ node, self }) =>\n          str(node, \"Node Type\") === \"Nested Loop\" &&\n          clampShare(self, rootTotal) >= LOOP_SHARE &&\n          loopRatio(node) > LOOP_RATIO,\n      )\n      .sort((a, b) => b.self - a.self);\n    const seenSignatures = new Set<string>();\n    for (const { node, self } of loopNodes) {\n      if (reported.size >= MAX_LOOP_FINDINGS) break;\n      const sig = loopSignature(node);\n      if (seenSignatures.has(sig)) continue;\n      seenSignatures.add(sig);\n      findings.push(buildLoopFinding(node, self, rootTotal));\n      reported.add(node);\n    }\n  }\n\n  // 1b. The single most expensive node, when it's a concentration we haven't\n  // already covered as a loop. A leaf (scan/sort/…) gets a plain concentration\n  // note; a dominant nested loop missed above (its cost is in its children, not\n  // multiplied) still gets the loop decomposition. Skipped for trivial\n  // single-node plans, where \"100% is one node\" says nothing.\n  if (rootTotal > 0 && nodes.length > 1) {\n    const top = nodes.reduce((best, candidate) =>\n      candidate.self > best.self ? candidate : best,\n    );\n    const share = clampShare(top.self, rootTotal);\n    if (share >= COST_CONCENTRATION_SHARE && !reported.has(top.node)) {\n      const nodeType = str(top.node, \"Node Type\");\n      if (nodeType === \"Nested Loop\") {\n        findings.push(buildLoopFinding(top.node, top.self, rootTotal));\n      } else if (subplanChildren(top.node).length > 0) {\n        // A node whose cost is correlated subqueries run per row — in the Filter\n        // (EXISTS) or the SELECT (scalar). The cost is the repetition, not the\n        // scan, so diagnose it as such instead of saying \"add an index\".\n        findings.push(buildSubplanFinding(top.node, top.self, rootTotal));\n      } else if (\n        INDEX_SERVED_NODES.has(nodeType ?? \"\") &&\n        optimization?.state === \"no_improvement_found\"\n      ) {\n        // Already index-served and the optimizer found no better index — no\n        // lever. But if it's an unfiltered whole-table read, the *reason* there's\n        // no lever (no predicate to narrow) is worth stating, so the query isn't\n        // left silent despite a real cost. Otherwise stay quiet (filtered scan,\n        // genuinely no better index).\n        if (isUnfilteredFullRead(top.node)) {\n          findings.push(\n            buildFullTableReadFinding(top.node, share, aggregateShape(root)),\n          );\n        }\n      } else {\n        const pct = Math.round(share * 100);\n        findings.push({\n          code: \"COST_CONCENTRATION\",\n          severity: share >= COST_CONCENTRATION_WARN_SHARE ? \"warning\" : \"info\",\n          impact: share,\n          title: `${pct}% of the cost is one node`,\n          detail: `About ${pct}% of this query's estimated cost is a single ${nodeLabel(\n            top.node,\n          )}. ${\n            share >= COST_CONCENTRATION_WARN_SHARE\n              ? \"It's the one thing worth tuning here.\"\n              : \"It's the biggest single contributor.\"\n          }${whyExpensive(top.node)}${\n            nodeType === \"Seq Scan\"\n              ? selectivityClause(top.node) + scanIndexClause\n              : sortHint(nodeType) + recommendedClause\n          }`,\n          evidence: {\n            node: nodeLabel(top.node),\n            costShare: `${pct}%`,\n            nodeCost: Math.round(top.self),\n            totalCost: Math.round(rootTotal),\n          },\n        });\n      }\n      reported.add(top.node);\n    }\n  }\n\n  // 2. Sequential scans that are expensive in their own right — by cost share or\n  // by raw rows scanned. A query can scan the same table in several subplans, so\n  // aggregate by relation: report each scanned table once, summing its cost share\n  // across nodes rather than emitting a near-identical finding per node. The\n  // dominant-node finding already names the single worst node, so skip that one.\n  const seqScansByRelation = new Map<\n    string,\n    {\n      self: number;\n      rows: number | undefined;\n      topNode: PlanNode;\n      topSelf: number;\n    }\n  >();\n  for (const { node, self } of nodes) {\n    if (str(node, \"Node Type\") !== \"Seq Scan\") continue;\n    if (reported.has(node)) continue;\n    const relation = str(node, \"Relation Name\") ?? str(node, \"Alias\") ?? \"?\";\n    const rows = num(node, \"Plan Rows\");\n    const entry = seqScansByRelation.get(relation) ?? {\n      self: 0,\n      rows: undefined,\n      topNode: node,\n      topSelf: -1,\n    };\n    entry.self += self;\n    // Keep the single heaviest contributing node so the cost-breakdown explainer\n    // describes the scan that actually dominates, not an arbitrary one.\n    if (self > entry.topSelf) {\n      entry.topSelf = self;\n      entry.topNode = node;\n    }\n    if (rows !== undefined) entry.rows = Math.max(entry.rows ?? 0, rows);\n    seqScansByRelation.set(relation, entry);\n  }\n  for (const [relation, { self, rows, topNode }] of seqScansByRelation) {\n    const share = clampShare(self, rootTotal);\n    const costHeavy = share >= SEQ_SCAN_COST_SHARE;\n    const rowHeavy = rows !== undefined && rows >= SEQ_SCAN_ROWS;\n    if (!costHeavy && !rowHeavy) continue;\n    const pct = Math.round(share * 100);\n    const named = relation !== \"?\";\n    // When the scan is selective, the clause states scanned-vs-returned in full,\n    // so the bare \"(~N rows)\" — which shows the post-filter *output* and reads as\n    // if the scan touched only that many — would contradict it. Drop it then.\n    const selectivity = selectivityClause(topNode);\n    const rowsText =\n      !selectivity && rows !== undefined\n        ? ` (~${rows.toLocaleString(\"en-US\")} rows)`\n        : \"\";\n    const scanned = selectivity ? rowsScanned(topNode) : undefined;\n    findings.push({\n      code: \"EXPENSIVE_SEQ_SCAN\",\n      severity: \"warning\",\n      impact: share,\n      title: `Sequential scan${named ? ` on ${relation}` : \"\"}`,\n      detail: `${\n        named ? `\\`${relation}\\`` : \"A table\"\n      } is read with a full sequential scan${rowsText}, about ${pct}% of the query's cost.${whyExpensive(topNode)}${selectivity}${scanIndexClause}`,\n      evidence: {\n        ...(named ? { relation } : {}),\n        ...(rows !== undefined ? { rows } : {}),\n        ...(scanned !== undefined ? { scanned } : {}),\n        costShare: `${pct}%`,\n      },\n    });\n  }\n\n  // 3. Unbounded result that ships a wide row set on every call. The MISSING_LIMIT\n  // nudge is the syntactic half; pairing it with the plan's estimated payload\n  // turns it into a concrete \"you move ~N MB per call, and it grows with the\n  // table\" verdict.\n  const hasMissingLimit = nudges.some(\n    (nudge) => nudge.kind === \"MISSING_LIMIT_CLAUSE\",\n  );\n  if (hasMissingLimit) {\n    const rows = num(root, \"Plan Rows\");\n    const width = num(root, \"Plan Width\");\n    if (rows !== undefined && width !== undefined) {\n      const bytes = rows * width;\n      if (bytes >= WIDE_RESULT_BYTES) {\n        findings.push({\n          code: \"WIDE_RESULT_NO_LIMIT\",\n          severity: bytes >= WIDE_RESULT_WARN_BYTES ? \"warning\" : \"info\",\n          // Payload size on the cost scale: ~1 MB ≈ 0.5, saturating below 1 so a\n          // big payload ranks alongside — but not above — a dominant cost node.\n          impact: Math.min(0.9, bytes / 2_000_000),\n          title: \"Unbounded result set\",\n          detail: `No LIMIT, and the query returns ~${rows.toLocaleString(\n            \"en-US\",\n          )} rows of ~${width} bytes (~${formatBytes(\n            bytes,\n          )} total). That width is a planner estimate and undercounts jsonb and large-text columns, so the real payload is likely bigger. To shrink it, drop columns from the SELECT or bound the rows.`,\n          evidence: {\n            rows,\n            rowWidth: width,\n            estimatedPayload: formatBytes(bytes),\n          },\n        });\n      }\n    }\n  }\n\n  // 4. Connect the function-on-column nudge to its plan consequence — but only\n  // for the table the function actually wraps. The nudge is AST-only (it knows a\n  // function wraps some column, not which table), so naming every sequentially\n  // scanned relation falsely blames tables scanned for unrelated reasons. Name\n  // only relations that are both sequentially scanned AND appear inside a\n  // function in a plan condition; with no such intersection there's no proven\n  // plan consequence, so the finding stays silent (the nudge still stands).\n  const hasFunctionOnColumn = nudges.some(\n    (nudge) => nudge.kind === \"AVOID_FUNCTIONS_ON_COLUMNS_IN_WHERE\",\n  );\n  if (hasFunctionOnColumn) {\n    const wrapped = relationsWrappedInFunction(root);\n    const seqScanned = new Set(\n      nodes\n        .filter(({ node }) => str(node, \"Node Type\") === \"Seq Scan\")\n        .map(({ node }) => str(node, \"Relation Name\"))\n        .filter((relation): relation is string => relation !== undefined),\n    );\n    const blocked = [...seqScanned].filter((relation) => wrapped.has(relation));\n    if (blocked.length > 0) {\n      const relationList = blocked.map((r) => `\\`${r}\\``).join(\", \");\n      // Rank by what the blocked scans actually cost — multiplied, since a\n      // function on a tiny lookup table run once per row is a red herring next\n      // to a dominant loop. This keeps the finding from looking as urgent as it\n      // isn't (on the dogfood loader, ci_repo_configs is ~2%, not headline).\n      const blockedCost = nodes\n        .filter(\n          ({ node }) =>\n            str(node, \"Node Type\") === \"Seq Scan\" &&\n            blocked.includes(str(node, \"Relation Name\") ?? \"\"),\n        )\n        .reduce((sum, { node }) => sum + multipliedCost(node), 0);\n      findings.push({\n        code: \"FUNCTION_ON_COLUMN_BLOCKS_INDEX\",\n        severity: \"warning\",\n        impact: clampShare(blockedCost, rootTotal),\n        title: \"A function on a column blocks an index\",\n        detail: `A condition wraps ${relationList}'s column in a function (e.g. \\`lower(col)\\`), so Postgres scans the table instead of using an index. Compare the bare column, or add an expression index for the function.${recommendedClause}`,\n        evidence: { sequentialScans: relationList },\n      });\n    }\n  }\n\n  // 5. In-memory sorts that cost real money. Postgres can skip a sort entirely\n  // when an index already returns rows in the wanted order, so a Sort node\n  // carrying a meaningful share of the cost is a standing \"an index in this\n  // order removes this\" signal. The dominant-node finding already names the\n  // single worst node, so skip it here.\n  const reportedSortKeys = new Set<string>();\n  const topNSorts = sortsBoundedByLimit(root);\n  for (const { node, self } of nodes) {\n    if (str(node, \"Node Type\") !== \"Sort\") continue;\n    if (reported.has(node)) continue;\n    const share = clampShare(self, rootTotal);\n    if (share < SORT_COST_SHARE) continue;\n    const sortKey = sortKeyText(node);\n    // The same ORDER BY can appear as twin sorts across subplans — report once.\n    const dedupeKey = sortKey ?? \"\";\n    if (reportedSortKeys.has(dedupeKey)) continue;\n    reportedSortKeys.add(dedupeKey);\n    const pct = Math.round(share * 100);\n    // A Sort a LIMIT bounds is a top-N: a matching index doesn't just skip the\n    // sort, it lets the scan stop after the first rows instead of ordering the\n    // whole set — the stronger, more concrete win. Only when the key is actually\n    // indexable (an aggregate/subquery sort key can't be pre-ordered either way).\n    const advice = sortKeyIsIndexable(sortKey)\n      ? topNSorts.has(node)\n        ? \"An index in that order would skip the sort and, with the LIMIT, let Postgres stop after the first rows instead of ordering the whole set.\"\n        : \"An index in that order would let Postgres skip the sort.\"\n      : \"The sort key is computed at runtime (an aggregate or subquery result), so no index can pre-sort it.\";\n    findings.push({\n      code: \"SORT_WITHOUT_INDEX\",\n      severity: share >= SORT_WARN_SHARE ? \"warning\" : \"info\",\n      impact: share,\n      title: \"In-memory sort\",\n      detail: `Rows are sorted in memory${\n        sortKey ? ` by \\`${sortKey}\\`` : \"\"\n      }, about ${pct}% of the query's cost. ${advice}`,\n      evidence: {\n        ...(sortKey ? { sortKey } : {}),\n        costShare: `${pct}%`,\n      },\n    });\n  }\n\n  // Dedup: a generic EXPENSIVE_SEQ_SCAN on a relation a more specific finding\n  // already explains — the function that blocks its index, or a loop's per-row\n  // driver — is the same scan said twice. Keep the specific one.\n  const explained = new Set<string>();\n  for (const finding of findings) {\n    if (finding.code === \"FUNCTION_ON_COLUMN_BLOCKS_INDEX\") {\n      const list = String(finding.evidence?.sequentialScans ?? \"\");\n      for (const match of list.matchAll(/`([^`]+)`/g)) explained.add(match[1]);\n    }\n    if (finding.code === \"REPEATED_INNER_LOOP\" && finding.evidence?.driver) {\n      explained.add(String(finding.evidence.driver));\n    }\n  }\n  const deduped = findings.filter(\n    (finding) =>\n      !(\n        finding.code === \"EXPENSIVE_SEQ_SCAN\" &&\n        explained.has(String(finding.evidence?.relation ?? \"\"))\n      ),\n  );\n\n  // Biggest lever first: a reader (or an agent) should act on the finding worth\n  // the most, not whichever happened to be detected first.\n  deduped.sort((a, b) => b.impact - a.impact);\n  return deduped;\n}\n\n/** Whether an index could pre-order this sort. A plain column (or a function of\n * one) can be served by an index; a sort on an aggregate, a subquery result, or\n * a computed comparison is produced at runtime, so no index can pre-order it —\n * promising one would be false advice. Unknown keys keep the generic advice. */\nfunction sortKeyIsIndexable(sortKey: string | undefined): boolean {\n  if (!sortKey) return true;\n  if (/\\bSubPlan\\b/i.test(sortKey)) return false;\n  if (\n    /\\b(count|sum|avg|min|max|jsonb_agg|array_agg|string_agg|bool_or|bool_and|row_number|rank|dense_rank|ntile)\\s*\\(/i.test(\n      sortKey,\n    )\n  ) {\n    return false;\n  }\n  // A whitespace-delimited comparison, e.g. ((a = b)) — a computed boolean, not a\n  // column. Requiring spaces around the operator avoids matching JSON path\n  // operators (`->`, `->>`), which contain `>` but are indexable via expression\n  // index.\n  if (/\\s(=|<|>|<=|>=|<>)\\s/.test(sortKey)) return false;\n  return true;\n}\n\n/** The Sort node's ORDER BY, as a compact string. `Sort Key` is a string array\n * (e.g. [\"created_at DESC\", \"id DESC\"]); join it and trim runaway length. */\nfunction sortKeyText(node: PlanNode): string | undefined {\n  const raw = node[\"Sort Key\"];\n  if (!Array.isArray(raw)) return undefined;\n  const keys = raw.filter((k): k is string => typeof k === \"string\");\n  if (keys.length === 0) return undefined;\n  const joined = keys.join(\", \");\n  return joined.length > 120 ? `${joined.slice(0, 117)}…` : joined;\n}\n"],"mappings":"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"}