From d94bea214d37a9652cfc2bc448995ff274dc2f7d Mon Sep 17 00:00:00 2001 From: Seth Raphael Date: Fri, 3 Jul 2026 00:01:44 -0700 Subject: [PATCH] monitor MCP 0.3.0: insights leg (occ) + lint leg (hook-parity anti-pattern rules) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leg 5 polls `npx convex insights` on the served cadence (default 10 min, CONVEX_MONITOR_INSIGHTS_INTERVAL_MS/-CMD seams for tests), snapshot-diffs against the previous poll, and emits kind `occ` with only the NEW write-conflict / read-limit lines plus the fix playbook. Hard-gated to cloud non-anonymous CONVEX_DEPLOYMENTs, silent on any poll failure. Leg 6 ports the Claude plugin's 12 PreToolUse hard-deny lint rules (convex-backend-skill hooks/convex-lint.mjs) into a pure module (convex-lint-rules.mjs) and runs them on a debounced convex/*.ts watch — Codex can't intercept a write before disk, so the pattern surfaces as the next event instead (kind `lint_error`), per-file signature dedupe, pre-existing findings baselined silently at first arm. Plugin 0.4.6 → 0.4.7. Tests: 23 rule unit tests + 10 new integration scenarios (fire/dedupe/baseline for both legs); 45/45 pass. Co-Authored-By: Claude Fable 5 --- plugins/convex/.codex-plugin/plugin.json | 4 +- plugins/convex/mcp/convex-lint-rules.mjs | 327 ++++++++++++++++++++++ plugins/convex/mcp/convex-monitor-mcp.mjs | 263 ++++++++++++++++- plugins/convex/mcp/lint-rules.test.mjs | 83 ++++++ plugins/convex/mcp/test.mjs | 95 +++++++ 5 files changed, 759 insertions(+), 13 deletions(-) create mode 100644 plugins/convex/mcp/convex-lint-rules.mjs create mode 100644 plugins/convex/mcp/lint-rules.test.mjs diff --git a/plugins/convex/.codex-plugin/plugin.json b/plugins/convex/.codex-plugin/plugin.json index 56c767c..82d48ef 100644 --- a/plugins/convex/.codex-plugin/plugin.json +++ b/plugins/convex/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "convex", - "version": "0.4.6", - "description": "Scaffold a running Next.js + Convex app from one sentence and build it live (locally), add capabilities from the Convex component ecosystem, and get a reactive, type-safe backend with database, server functions, and scaling guidance — plus a convex-expert subagent, a convex-reviewer, and live error-watching.", + "version": "0.4.7", + "description": "Scaffold a running Next.js + Convex app from one sentence and build it live (locally), add capabilities from the Convex component ecosystem, and get a reactive, type-safe backend with database, server functions, and scaling guidance — plus a convex-expert subagent, a convex-reviewer, live error-watching, deployment performance insights, and Convex anti-pattern linting.", "author": { "name": "Convex, Inc.", "email": "support@convex.dev", diff --git a/plugins/convex/mcp/convex-lint-rules.mjs b/plugins/convex/mcp/convex-lint-rules.mjs new file mode 100644 index 0000000..ee744ec --- /dev/null +++ b/plugins/convex/mcp/convex-lint-rules.mjs @@ -0,0 +1,327 @@ +// convex-lint-rules.mjs — pure lint rules for Convex source files, used by the +// monitor MCP's lint leg (kind `lint_error`). +// +// PROVENANCE / DRIFT NOTE: these are the same hard-deny rules as the Claude +// plugin's PreToolUse hook (convex-backend-skill hooks/convex-lint.mjs, v1.7.6) +// — Codex has no pre-tool-use hook, so instead of blocking the write before it +// lands, the MCP lint leg detects the pattern seconds after the file changes +// and surfaces it as the next event the agent sees (before a `convex dev` push +// cycle is wasted on it). Until the rules are extracted into a served/shared +// module (convex-agents content/), the hook file is the source of truth: port +// rule changes here, keeping regexes identical. +// +// Pure module: `lintConvexSource(relPath, content)` → [{ rule, message }]. +// No I/O, no process, no side effects — the leg decides watch/debounce/dedupe. +// Deny discipline (same as the hook): only patterns that are UNAMBIGUOUS in a +// convex/*.ts source file; a false positive is the worst outcome. + +// Ground truth for the "convex/server bad symbol" rule: the real named exports +// of the `convex/server` package entrypoint. Generated with: +// cd /tmp && npm i convex --no-save && node -e \ +// 'console.log(JSON.stringify(Object.keys(require("convex/server"))))' +// against convex@1.42.1 (2026-07-02). Static snapshot on purpose: the target +// project may have a different (or no) `convex` resolution from this process. +const CONVEX_SERVER_EXPORTS = new Set([ + "HttpRouter", + "ROUTABLE_HTTP_METHODS", + "actionGeneric", + "anyApi", + "componentsGeneric", + "createFunctionHandle", + "cronJobs", + "currentSystemUdfInComponent", + "defineApp", + "defineComponent", + "defineSchema", + "defineTable", + "filterApi", + "getFunctionAddress", + "getFunctionName", + "httpActionGeneric", + "httpRouter", + "internalActionGeneric", + "internalMutationGeneric", + "internalQueryGeneric", + "log", + "makeFunctionReference", + "mutationGeneric", + "queryGeneric", + "paginationOptsValidator", + "paginationResultValidator", + "SearchFilter", +]); + +const JS_RESERVED_WORDS = [ + "delete", "new", "class", "function", "return", "import", "default", + "typeof", "void", "if", "else", "for", "while", "do", "switch", "case", + "break", "continue", "try", "catch", "finally", "throw", "instanceof", + "in", "this", "super", "extends", "export", "const", "let", "var", + "null", "true", "false", "yield", "await", "static", "enum", +]; + +const NODE_BUILTINS = [ + "crypto", "fs", "path", "http", "https", "child_process", "os", "net", + "tls", "dns", "stream", "zlib", "util", "buffer", "events", "url", + "querystring", "assert", "cluster", "dgram", "readline", "repl", "vm", + "worker_threads", "perf_hooks", +]; + +function snippet(text) { + const oneLine = String(text).replace(/\s+/g, " ").trim(); + return oneLine.length > 120 ? `${oneLine.slice(0, 120)}…` : oneLine; +} + +// Lint one convex/ TypeScript source. `relPath` is the project-relative path +// (used for schema.ts scoping); `content` is the full file text. +// Returns findings in rule order; empty array = clean. +export function lintConvexSource(relPath, content) { + const findings = []; + const add = (rule, message) => findings.push({ rule, message }); + const normalized = String(relPath).replaceAll("\\", "/"); + if ( + !/(^|\/)convex\//.test(normalized) || + !normalized.endsWith(".ts") || + normalized.endsWith(".d.ts") || + normalized.includes("/_generated/") + ) { + return findings; + } + const projected = String(content); + + // Rule: `.filter(q => … q.field(…))` on a db query — full-table scan. + const dbFilterRe = + /\.filter\(\s*\(?\s*(\w+)\s*\)?\s*=>[\s\S]{0,200}?\b\1\.field\(/; + const dbFilterMatch = dbFilterRe.exec(projected); + if (dbFilterMatch) { + add( + "db_filter", + `\`${snippet(dbFilterMatch[0])}\` — \`.filter\` scans the whole table on every call. ` + + `Use \`.withIndex("by_...", q => q.eq(...))\` with an index defined in convex/schema.ts ` + + `(\`.index("by_", [""])\`) instead.`, + ); + } + + // Rule: old positional function syntax `query(async (ctx, …) => …)`. + const positionalRe = + /\b(query|mutation|action|internalQuery|internalMutation|internalAction)\(\s*async\s*\(/; + const positionalMatch = positionalRe.exec(projected); + if (positionalMatch) { + add( + "positional_syntax", + `\`${snippet(positionalMatch[0])}\` — passing a bare async handler to \`${positionalMatch[1]}\` ` + + `is the deprecated positional form. Use the object form: ` + + `${positionalMatch[1]}({ args: {...}, returns: ..., handler: async (ctx, args) => {...} }).`, + ); + } + + // Rule: function constructors imported from "convex/server" — they live in + // the generated ./_generated/server. + const serverPkgImportRe = + /import\s*\{([^}]*)\}\s*from\s*["']convex\/server["']/g; + let serverPkgMatch; + while ((serverPkgMatch = serverPkgImportRe.exec(projected)) !== null) { + const names = serverPkgMatch[1]; + const fnNameRe = + /(^|[,\s])(query|mutation|action|internalQuery|internalMutation|internalAction)($|[,\s:])/; + if (fnNameRe.test(names)) { + add( + "server_pkg_import", + `\`${snippet(serverPkgMatch[0])}\` — \`query\`/\`mutation\`/\`action\` (and internal* variants) ` + + `are exported from the generated \`./_generated/server\`, not the \`convex/server\` package. ` + + `Fix: \`import { ${names.trim()} } from "./_generated/server";\`.`, + ); + } + } + + // Rule: `internal`/`api` imported from ./_generated/server — they live in + // ./_generated/api. + const genServerImportRe = + /import\s*\{([^}]*)\}\s*from\s*["']\.\/_generated\/server["']/g; + let genServerMatch; + while ((genServerMatch = genServerImportRe.exec(projected)) !== null) { + const names = genServerMatch[1]; + const badNameRe = /(^|[,\s])(internal|api)($|[,\s:])/; + if (badNameRe.test(names)) { + add( + "generated_server_import", + `\`${snippet(genServerMatch[0])}\` — \`internal\`/\`api\` are exported from \`./_generated/api\`, ` + + `not \`./_generated/server\`. Fix: \`import { internal, api } from "./_generated/api";\` ` + + `(keep \`query\`/\`mutation\` etc. on \`./_generated/server\`).`, + ); + } + } + + // Rule: `"use node"` in a file that also defines query( / mutation(. + const useNodeRe = /^\s*["']use node["'];?\s*$/m; + if (useNodeRe.test(projected)) { + const queryOrMutationRe = /\b(query|mutation)\s*\(/; + const qmMatch = queryOrMutationRe.exec(projected); + if (qmMatch) { + add( + "use_node_query_mutation", + `this file has \`"use node"\` and also defines \`${snippet(qmMatch[0])}…)\` — queries and ` + + `mutations cannot run in the Node.js runtime, only actions can. Move ${qmMatch[1]} ` + + `definitions to a file without \`"use node"\`, or convert to an \`action\` that calls ` + + `a query/mutation via \`ctx.runQuery\`/\`ctx.runMutation\`.`, + ); + } + } + + // Rule: a named import from "convex/server" that isn't a real export. + const serverPkgAnyImportRe = + /import\s*\{([^}]*)\}\s*from\s*["']convex\/server["']/g; + let serverAnyMatch; + while ((serverAnyMatch = serverPkgAnyImportRe.exec(projected)) !== null) { + const parts = serverAnyMatch[1].split(",").map((p) => p.trim()).filter(Boolean); + for (const part of parts) { + const exportedName = part.split(/\s+as\s+/)[0].trim(); + if (!exportedName) continue; + if (!CONVEX_SERVER_EXPORTS.has(exportedName)) { + const hint = + exportedName === "HttpResponse" + ? `\`HttpResponse\` doesn't exist — use \`httpAction\` from \`./_generated/server\` ` + + `and return a web-standard \`Response\`.` + : `\`${exportedName}\` is not exported by \`convex/server\`.`; + add( + "server_pkg_bad_symbol", + `\`${snippet(serverAnyMatch[0])}\` — ${hint} \`query\`/\`mutation\`/\`action\`/` + + `\`httpAction\`/\`internal\`/\`api\` come from \`./_generated/server\` or ` + + `\`./_generated/api\` instead.`, + ); + } + } + } + + // Rules scoped to schema.ts: reserved index/table names. + const isSchemaFile = /(^|\/)schema\.ts$/.test(normalized); + if (isSchemaFile) { + const indexCallRe = /\.index\(\s*(["'`])((?:(?!\1).)*)\1\s*,\s*(\[[^\]]*\])/g; + let indexMatch; + while ((indexMatch = indexCallRe.exec(projected)) !== null) { + const indexName = indexMatch[2]; + const fieldsLiteral = indexMatch[3]; + if (indexName === "by_id" || indexName === "by_creation_time" || indexName.startsWith("_")) { + add( + "reserved_index_name", + `\`${snippet(indexMatch[0])}\` — \`${indexName}\` is a reserved index name ` + + `(\`by_id\`/\`by_creation_time\`/\`_\`-prefixed are reserved; Convex auto-appends ` + + `\`_creationTime\` as the implicit tiebreaker). Rename to describe the field(s), ` + + `e.g. \`.index("by_", [...])\`.`, + ); + } + if (/_creationTime/.test(fieldsLiteral)) { + add( + "reserved_index_name", + `\`${snippet(indexMatch[0])}\` — \`_creationTime\` cannot be listed as an index field; ` + + `Convex auto-appends it as the implicit tiebreaker on every index. Remove it from ` + + `the fields array.`, + ); + } + } + const reservedTableRe = + /(^|[{,]\s*)(["'`]?)(_[A-Za-z0-9_]*)\2\s*:\s*defineTable\s*\(/g; + let tableMatch; + while ((tableMatch = reservedTableRe.exec(projected)) !== null) { + const tableName = tableMatch[3]; + add( + "reserved_table_name", + `\`${snippet(`${tableName}: defineTable(`)}\` — \`${tableName}\` is a reserved table name ` + + `(names starting with \`_\` are reserved). Rename it, e.g. ` + + `\`${tableName.replace(/^_+/, "")}: defineTable(...)\`.`, + ); + } + } + + // Rule: `export const = ...` — esbuild hard failure. + const reservedIdentifierRe = new RegExp( + `export\\s+const\\s+(${JS_RESERVED_WORDS.join("|")})\\s*=`, + "g", + ); + let reservedIdMatch; + while ((reservedIdMatch = reservedIdentifierRe.exec(projected)) !== null) { + const word = reservedIdMatch[1]; + add( + "reserved_identifier", + `\`${snippet(reservedIdMatch[0])}\` — \`${word}\` is a JS reserved word and can't be an ` + + `export name (esbuild: "Expected identifier but found \\"${word}\\""). Rename it, e.g. ` + + `\`export const remove = ...\` instead of \`export const ${word} = ...\`.`, + ); + } + + // Rule: Node builtin imported/required without `"use node"` — the default + // V8-isolate runtime has no Node builtins. + if (!useNodeRe.test(projected)) { + const nodeImportRe = new RegExp( + `(?:import\\s+(?:[\\w*{}\\s,]+\\s+from\\s+)?|require\\(\\s*)` + + `["'](?:node:)?(${NODE_BUILTINS.join("|")})["']`, + "g", + ); + let nodeImportMatch; + while ((nodeImportMatch = nodeImportRe.exec(projected)) !== null) { + const mod = nodeImportMatch[1]; + add( + "node_api_without_use_node", + `\`${snippet(nodeImportMatch[0])}\` — \`${mod}\` is a Node.js builtin but this file has no ` + + `\`"use node"\` directive; the default Convex runtime is a V8 isolate, so esbuild fails ` + + `to resolve it. Move the code to a \`"use node";\` action file, or for \`crypto\` use ` + + `the Web Crypto API via \`globalThis.crypto\` (no import needed).`, + ); + } + } + + // Rule: Express-style `:param` path in an http route — permanently dead code. + const httpParamRouteRe = + /\bpath\s*:\s*(["'`])((?:(?!\1).)*\/:[A-Za-z_][A-Za-z0-9_]*(?:(?!\1).)*)\1/g; + let httpParamMatch; + while ((httpParamMatch = httpParamRouteRe.exec(projected)) !== null) { + const routePath = httpParamMatch[2]; + add( + "http_router_param_route", + `\`${snippet(httpParamMatch[0])}\` — Convex's \`httpRouter\` has no Express-style \`:param\` ` + + `segments; \`path: "${routePath}"\` only matches that literal string (unreachable dead ` + + `code). Use \`pathPrefix: "${routePath.split("/:")[0]}/"\` and parse the trailing segment ` + + `from \`new URL(request.url).pathname\`.`, + ); + } + + // Rule: http.route handler not wrapped in httpAction(...). + const httpRouteBlockRe = /\bhttp\.route\(\s*\{/g; + let httpRouteBlockMatch; + while ((httpRouteBlockMatch = httpRouteBlockRe.exec(projected)) !== null) { + const blockSlice = projected.slice(httpRouteBlockMatch.index, httpRouteBlockMatch.index + 400); + const handlerMatch = + /\bhandler\s*:\s*(httpAction\s*\(|async\s*(?:\([^)]*\)|\w+)\s*=>|async\s+function\b|function\b)/.exec(blockSlice); + if (handlerMatch && !/^httpAction\s*\(/.test(handlerMatch[1])) { + add( + "http_route_handler_not_wrapped", + `\`${snippet(handlerMatch[0])}\` inside \`http.route({...})\` — the \`handler:\` must be ` + + `wrapped in \`httpAction(...)\` (from \`./_generated/server\`), e.g. ` + + `\`handler: httpAction(async (ctx, request) => { ... return new Response(...); })\`.`, + ); + } + } + + // Rule: `.range(...)` inside a withIndex index-range callback — not a method + // on IndexRangeBuilder (only eq/gt/gte/lt/lte, chained directly). + const withIndexBlockRe = /\.withIndex\(\s*(["'`])(?:(?!\1).)*\1\s*,\s*\(?\s*(\w+)\s*\)?\s*=>/g; + let withIndexMatch; + while ((withIndexMatch = withIndexBlockRe.exec(projected)) !== null) { + const param = withIndexMatch[2]; + const bodyStart = withIndexMatch.index + withIndexMatch[0].length; + const bodySlice = projected.slice(bodyStart, bodyStart + 300); + const terminatorMatch = /\n\s*\)\s*\.|\n\s*\)\s*;|\n\s*\}\s*\)/.exec(bodySlice); + const body = terminatorMatch ? bodySlice.slice(0, terminatorMatch.index) : bodySlice; + const rangeCallRe = new RegExp(`\\b${param}\\.[a-zA-Z]+\\([^)]*\\)\\.range\\(`); + const rangeMatch = rangeCallRe.exec(body) || /\)\.range\(/.exec(body); + if (rangeMatch) { + add( + "withindex_range_method", + `\`${snippet(rangeMatch[0])}\` inside \`.withIndex(...)\` — \`.range(...)\` is not a method ` + + `on Convex's \`IndexRangeBuilder\`; only \`eq\`/\`gt\`/\`gte\`/\`lt\`/\`lte\` exist, ` + + `chained directly, e.g. \`q.eq("field", v).lte("other", x)\`.`, + ); + } + } + + return findings; +} diff --git a/plugins/convex/mcp/convex-monitor-mcp.mjs b/plugins/convex/mcp/convex-monitor-mcp.mjs index 41e5519..8b5c4a8 100644 --- a/plugins/convex/mcp/convex-monitor-mcp.mjs +++ b/plugins/convex/mcp/convex-monitor-mcp.mjs @@ -11,6 +11,13 @@ // leg 3 (robust) fs.watch on the local *-errors.log files → convex/next errors // leg 4 (typecheck) debounced fs.watch on convex/*.ts → `convex codegen` + `tsc // --noEmit`, deduped so an unchanged error doesn't re-notify +// leg 5 (insights) `npx convex insights` polled on the served cadence (default +// 10 min), snapshot-diffed → NEW OCC write-conflict / read-limit +// findings only (kind `occ`); silent on local/anonymous deploys +// leg 6 (lint) debounced fs.watch on convex/*.ts → pure-regex Convex +// anti-pattern rules (see convex-lint-rules.mjs; the same hard +// denies as the Claude plugin's PreToolUse hook, which Codex +// can't run) → kind `lint_error`, per-file signature dedupe // heartbeat after timeoutMs, returns { kind: "quiet" } so the agent re-arms // // Why a blocking tool instead of polling: the agent's idle action becomes @@ -36,8 +43,10 @@ import { promisify } from "node:util"; const exec = promisify(execCb); +import { lintConvexSource } from "./convex-lint-rules.mjs"; + const SERVER_NAME = "convex-plugin"; -const SERVER_VERSION = "0.2.1"; +const SERVER_VERSION = "0.3.0"; const PROTOCOL_VERSION = "2024-11-05"; const log = (...a) => process.stderr.write("[convex-plugin] " + a.join(" ") + "\n"); @@ -63,6 +72,7 @@ const BAKED_CONFIG = { defaultQueries: ["featureRequests:listPending"], patternByKind: {}, // no per-kind line preference → last appended line wins pollMsByKind: {}, // → 500ms backup size-poll for every watched log + intervalSecByKind: {}, // → baked per-leg cadences (insights: 600s) kindSummaries: null, // → baked tool description only }; @@ -96,7 +106,7 @@ function disableServedPattern(kind, why) { } function specToConfig(spec) { - const cfg = { source: "served", defaultQueries: [], patternByKind: {}, pollMsByKind: {}, kindSummaries: [] }; + const cfg = { source: "served", defaultQueries: [], patternByKind: {}, pollMsByKind: {}, intervalSecByKind: {}, kindSummaries: [] }; for (const m of spec.monitors) { if (!m || typeof m.kind !== "string") continue; if (typeof m.pattern === "string" && m.pattern) { @@ -105,6 +115,9 @@ function specToConfig(spec) { else try { cfg.patternByKind[m.kind] = new RegExp(m.pattern, "i"); } catch {} } if (Number.isFinite(m.intervalSec)) { + // Raw served cadence, for polling legs (insights) whose interval IS the + // shell-loop cadence rather than a log-watch backup poll. + cfg.intervalSecByKind[m.kind] = m.intervalSec; // The served intervalSec is the shell-loop cadence on other harnesses; our // fs.watch leg is realtime with a backup size-poll — scale interval/10, // clamped to [250, 1000]ms so responsiveness never regresses below today's. @@ -372,6 +385,224 @@ function armTypecheckLeg(projectDir, finish, cleanups) { return true; } +// ------------------------------------------------------------- leg 5: insights +// Poll `npx convex insights` on the served cadence (default 600s) and surface +// only NEW OCC write-conflict / read-limit lines, snapshot-diffed — the same +// semantics as the Claude shell monitor (first successful poll is a silent +// baseline; the baseline is replaced on every poll so each notification means +// something NEW went wrong). State is module-level so the cadence and the +// baseline survive across repeated (loop) calls to fix_errors_automatically: +// each call polls immediately iff the interval has elapsed since the last poll, +// otherwise it schedules the poll for when it comes due inside this call. +const INSIGHTS_INTERVAL_MS_BAKED = 600_000; +const INSIGHTS_PATTERN_BAKED = /occ|conflict|bytes read|documents read|limit|threshold/i; +const insightsStateByProject = new Map(); // dir → { baseline: Set|null, lastPollAt: 0, running: false } + +// Self-guard: `npx convex insights` only works for cloud deployments with +// user-level auth — local/anonymous deployments error. Hard-gate on .env.local +// having a non-anonymous CONVEX_DEPLOYMENT so the leg never even starts there. +function insightsLegEligible(projectDir) { + try { + const txt = fs.readFileSync(path.join(projectDir, ".env.local"), "utf8"); + const m = txt.match(/^CONVEX_DEPLOYMENT=(.+)$/m); + if (!m) return false; + const v = m[1].trim().replace(/^["']|["']$/g, ""); + return !!v && !/^anonymous/i.test(v); + } catch { + return false; + } +} + +// Injectable/mockable exec seam (same idiom as CONVEX_MONITOR_TSC_CMD). +async function runInsights(projectDir) { + const cmd = process.env.CONVEX_MONITOR_INSIGHTS_CMD || "npx convex insights"; + return exec(cmd, { cwd: projectDir, timeout: 60_000, maxBuffer: 10 * 1024 * 1024 }); +} + +function armInsightsLeg(projectDir, finish, cleanups) { + if (!insightsLegEligible(projectDir)) { + log("insights leg: no cloud CONVEX_DEPLOYMENT in .env.local — skipping"); + return null; + } + const intervalMs = Math.max( + 5_000, + Number(process.env.CONVEX_MONITOR_INSIGHTS_INTERVAL_MS) || + (Number.isFinite(CONFIG.intervalSecByKind?.occ) + ? CONFIG.intervalSecByKind.occ * 1000 + : INSIGHTS_INTERVAL_MS_BAKED), + ); + let st = insightsStateByProject.get(projectDir); + if (!st) { + st = { baseline: null, lastPollAt: 0, running: false }; + insightsStateByProject.set(projectDir, st); + } + const pollOnce = async () => { + if (st.running) return; + st.running = true; + try { + const { stdout } = await runInsights(projectDir); + st.lastPollAt = Date.now(); + const re = CONFIG.patternByKind.occ || INSIGHTS_PATTERN_BAKED; + const lines = String(stdout || "") + .split("\n") + .map((s) => s.trim()) + .filter(Boolean) + .filter((l) => { + const t0 = Date.now(); + let hit = false; + try { hit = re.test(l); } catch { hit = false; } + if (Date.now() - t0 > PATTERN_EXEC_BUDGET_MS) { + disableServedPattern("occ", `single exec took >${PATTERN_EXEC_BUDGET_MS}ms`); + try { return INSIGHTS_PATTERN_BAKED.test(l); } catch { return false; } + } + return hit; + }); + const set = new Set(lines); + if (st.baseline === null) { + st.baseline = set; // first successful poll = silent baseline + return; + } + const fresh = lines.filter((l) => !st.baseline.has(l)); + st.baseline = set; // baseline is always the latest snapshot + if (fresh.length) { + finish({ + kind: "occ", + source: "npx convex insights", + count: fresh.length, + line: fresh.slice(0, 10).join("\n"), + note: "new deployment insight(s) — run `npx convex insights --details` to see which function and table; shrink the transaction / use @convex-dev/aggregate for hot counters / add .withIndex() or .paginate() for read limits", + }); + } + } catch (e) { + // Non-zero exit / timeout / auth error → silent (matches the shell + // monitor: local or key-authed deployments simply don't get this leg). + st.lastPollAt = Date.now(); + log(`insights leg: poll failed (${String(e?.message || e).slice(0, 120)}) — silent`); + } finally { + st.running = false; + } + }; + const dueInMs = st.lastPollAt + intervalMs - Date.now(); + let dueTimer = null; + if (dueInMs <= 0) pollOnce(); + else dueTimer = setTimeout(pollOnce, dueInMs); + const iv = setInterval(pollOnce, intervalMs); + cleanups.push(() => { if (dueTimer) clearTimeout(dueTimer); clearInterval(iv); }); + log(`insights leg: polling deployment insights every ${Math.round(intervalMs / 1000)}s`); + return true; +} + +// ---------------------------------------------------------------- leg 6: lint +// Debounced fs.watch on convex/*.ts → pure-regex anti-pattern rules +// (convex-lint-rules.mjs — the same hard denies as the Claude plugin's +// PreToolUse hook). Codex has no pre-tool-use hook to block the write before +// it lands, so this leg surfaces the pattern seconds after the file changes, +// before a `convex dev` push cycle is wasted on it. Per-file finding-signature +// dedupe (module-level, survives across calls); the findings present when the +// leg FIRST arms in this server process are baselined silently — parity with +// the hook, which only ever fires on new writes. +const LINT_DEBOUNCE_MS = 300; +const lintSigsByProject = new Map(); // dir → Map(relFile → findings signature) + +function scanConvexDirForLint(projectDir) { + const convexDir = path.join(projectDir, "convex"); + const out = new Map(); // relFile → findings[] + let entries = []; + try { entries = fs.readdirSync(convexDir); } catch { return out; } + for (const f of entries) { + if (!f.endsWith(".ts") || f.endsWith(".d.ts")) continue; + let content; + try { content = fs.readFileSync(path.join(convexDir, f), "utf8"); } catch { continue; } + let findings = []; + try { findings = lintConvexSource("convex/" + f, content); } catch { continue; } + if (findings.length) out.set("convex/" + f, findings); + } + return out; +} + +const lintSignature = (findings) => JSON.stringify(findings.map((f) => f.rule + ":" + f.message)); + +function armLintLeg(projectDir, finish, cleanups) { + const convexDir = path.join(projectDir, "convex"); + try { + if (!fs.statSync(convexDir).isDirectory()) return null; + } catch { + log("lint leg: no convex/ dir — skipping"); + return null; + } + let sigs = lintSigsByProject.get(projectDir); + if (!sigs) { + sigs = new Map(); + for (const [file, findings] of scanConvexDirForLint(projectDir)) { + sigs.set(file, lintSignature(findings)); // silent baseline on first arm + } + lintSigsByProject.set(projectDir, sigs); + } + let debounceTimer = null; + const scan = () => { + try { + const current = scanConvexDirForLint(projectDir); + for (const [file, findings] of current) { + const sig = lintSignature(findings); + if (sigs.get(file) !== sig) { + sigs.set(file, sig); + const first = findings[0]; + finish({ + kind: "lint_error", + file, + rule: first.rule, + count: findings.length, + line: `convex-lint rule "${first.rule}": ${first.message}`, + }); + return; + } + } + // Files that are now clean drop their signature, so a later regression + // to the same finding re-notifies instead of being suppressed. + for (const file of [...sigs.keys()]) if (!current.has(file)) sigs.delete(file); + } catch (e) { + log(`lint leg: scan failed (${e.message})`); + } + }; + const onChange = (_evt, filename) => { + if (filename && !String(filename).endsWith(".ts")) return; + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(scan, LINT_DEBOUNCE_MS); + }; + let watcher; + try { + watcher = fs.watch(convexDir, { persistent: true }, onChange); + } catch (e) { + log(`lint leg: watch failed for ${convexDir}: ${e.message}`); + return null; + } + cleanups.push(() => { try { watcher.close(); } catch {} }); + cleanups.push(() => { if (debounceTimer) clearTimeout(debounceTimer); }); + // Same atomic-save blind spot as leg 4: back the watch with a light mtime poll. + let lastMtimeSig = ""; + const snapshotMtimes = () => { + let sig = ""; + try { + for (const f of fs.readdirSync(convexDir)) { + if (!f.endsWith(".ts")) continue; + try { sig += f + ":" + fs.statSync(path.join(convexDir, f)).mtimeMs + ";"; } catch {} + } + } catch { /* leave sig empty */ } + return sig; + }; + lastMtimeSig = snapshotMtimes(); + const iv = setInterval(() => { + const sig = snapshotMtimes(); + if (sig !== lastMtimeSig) { + lastMtimeSig = sig; + onChange(null, "poll.ts"); + } + }, 500); + cleanups.push(() => clearInterval(iv)); + log(`lint leg: watching ${convexDir}`); + return true; +} + // --------------------------------------------------------------- the race async function waitForConvexEvent(args = {}) { // The MCP server may be launched with cwd = plugin root (not the project), so @@ -460,6 +691,12 @@ async function waitForConvexEvent(args = {}) { // --- leg 4: debounced typecheck (convex codegen + tsc --noEmit) ------ armTypecheckLeg(projectDir, finish, cleanups); + // --- leg 5: deployment insights (OCC / read limits), snapshot-diffed -- + armInsightsLeg(projectDir, finish, cleanups); + + // --- leg 6: convex/*.ts anti-pattern lint (hook parity) --------------- + armLintLeg(projectDir, finish, cleanups); + // --- leg 1: Convex subscription (reactive) --------------------------- const url = readConvexUrl(projectDir); if (url) { @@ -542,15 +779,19 @@ const TOOL_DESCRIPTION_BAKED = "event in this project and returns it — this tool is read-only (it watches; YOU do the " + "fix with your normal edit/run tools). Races: (1) the local *-errors.log files for " + "convex/next compile/runtime errors, (2) a Convex subscription for new feature " + - "requests / refinement answers, and (3) a debounced `convex codegen` + `tsc --noEmit` " + - "typecheck on convex/*.ts changes. Returns one typed event { kind: feature_request | " + - "refinement_answer | convex_error | next_error | typecheck_error | quiet }. Use it as " + - "your standing idle action: after the app's first version is up, call this in a loop " + - "instead of yielding — each call blocks, so you stay on watch and react the instant " + - "something happens (an error to fix, or a user request to build). On a " + - "'convex_error'/'next_error'/'typecheck_error' stop and fix it; on a request/answer " + - "handle it; on 'quiet' (heartbeat timeout) just call again. ALWAYS pass projectDir = " + - "the absolute path of the app you scaffolded."; + "requests / refinement answers, (3) a debounced `convex codegen` + `tsc --noEmit` " + + "typecheck on convex/*.ts changes, (4) `npx convex insights` polled for NEW OCC " + + "write-conflict / read-limit findings (cloud deployments only), and (5) a Convex " + + "anti-pattern lint on convex/*.ts changes (e.g. .filter on a db query, reserved " + + "index/table names, Node builtins without \"use node\"). Returns one typed event " + + "{ kind: feature_request | refinement_answer | convex_error | next_error | " + + "typecheck_error | occ | lint_error | quiet }. Use it as your standing idle action: " + + "after the app's first version is up, call this in a loop instead of yielding — each " + + "call blocks, so you stay on watch and react the instant something happens (an error " + + "to fix, or a user request to build). On a 'convex_error'/'next_error'/" + + "'typecheck_error'/'lint_error' stop and fix it; on 'occ' follow the note's playbook; " + + "on a request/answer handle it; on 'quiet' (heartbeat timeout) just call again. " + + "ALWAYS pass projectDir = the absolute path of the app you scaffolded."; const TOOL_INPUT_SCHEMA = { type: "object", diff --git a/plugins/convex/mcp/lint-rules.test.mjs b/plugins/convex/mcp/lint-rules.test.mjs new file mode 100644 index 0000000..db84998 --- /dev/null +++ b/plugins/convex/mcp/lint-rules.test.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +// lint-rules.test.mjs — unit tests for convex-lint-rules.mjs (the lint leg's +// pure rule module). Each case: one source string → expected rule id (or clean). +import { lintConvexSource } from "./convex-lint-rules.mjs"; + +let pass = 0, fail = 0; +const ok = (name, cond, extra = "") => { (cond ? pass++ : fail++); console.log(`${cond ? "✅" : "❌"} ${name}${extra ? " " + extra : ""}`); }; + +const rulesOf = (relPath, src) => lintConvexSource(relPath, src).map((f) => f.rule); + +// --- hard-deny rules fire ----------------------------------------------- +ok("db_filter: .filter(q => q.field(...)) on a db query", + rulesOf("convex/messages.ts", `export const list = query({ args: {}, handler: async (ctx) => ctx.db.query("messages").filter(q => q.eq(q.field("channel"), "x")).collect() });`).includes("db_filter")); + +ok("positional_syntax: query(async (ctx) => ...)", + rulesOf("convex/messages.ts", `export const list = query(async (ctx) => { return []; });`).includes("positional_syntax")); + +ok("server_pkg_import: query imported from convex/server", + rulesOf("convex/messages.ts", `import { query } from "convex/server";`).includes("server_pkg_import")); + +ok("generated_server_import: api from ./_generated/server", + rulesOf("convex/http.ts", `import { api } from "./_generated/server";`).includes("generated_server_import")); + +ok("use_node_query_mutation: \"use node\" + query(", + rulesOf("convex/stuff.ts", `"use node";\nimport { query } from "./_generated/server";\nexport const q = query({ args: {}, handler: async () => 1 });`).includes("use_node_query_mutation")); + +ok("server_pkg_bad_symbol: hallucinated HttpResponse", + rulesOf("convex/http.ts", `import { HttpResponse } from "convex/server";`).includes("server_pkg_bad_symbol")); + +ok("reserved_index_name: by_id in schema.ts", + rulesOf("convex/schema.ts", `export default defineSchema({ t: defineTable({ a: v.string() }).index("by_id", ["a"]) });`).includes("reserved_index_name")); + +ok("reserved_index_name: _creationTime listed as index field", + rulesOf("convex/schema.ts", `defineTable({ a: v.string() }).index("by_a", ["a", "_creationTime"])`).includes("reserved_index_name")); + +ok("reserved_table_name: _migrations table in schema.ts", + rulesOf("convex/schema.ts", `export default defineSchema({ _migrations: defineTable({ v: v.number() }) });`).includes("reserved_table_name")); + +ok("reserved_identifier: export const delete", + rulesOf("convex/tasks.ts", `export const delete = mutation({ args: {}, handler: async () => {} });`).includes("reserved_identifier")); + +ok("node_api_without_use_node: import crypto without directive", + rulesOf("convex/http.ts", `import crypto from "crypto";\nexport const x = 1;`).includes("node_api_without_use_node")); + +ok("node_api_without_use_node: node: prefix too", + rulesOf("convex/http.ts", `import { readFileSync } from "node:fs";`).includes("node_api_without_use_node")); + +ok("http_router_param_route: /tasks/:id", + rulesOf("convex/http.ts", `http.route({ path: "/tasks/:id", method: "GET", handler: httpAction(async () => new Response()) });`).includes("http_router_param_route")); + +ok("http_route_handler_not_wrapped: bare async handler", + rulesOf("convex/http.ts", `http.route({ path: "/tasks", method: "GET", handler: async (ctx, request) => new Response("ok") });`).includes("http_route_handler_not_wrapped")); + +ok("withindex_range_method: .range( inside withIndex callback", + rulesOf("convex/dashboard.ts", `const rows = await ctx.db.query("alerts").withIndex("by_ack", (q) => q.eq("acknowledged", false).range(0, 10)).collect();`).includes("withindex_range_method")); + +// --- clean sources stay clean (false-positive discipline) ---------------- +ok("clean: object-form query with withIndex", + rulesOf("convex/messages.ts", `import { query } from "./_generated/server";\nexport const list = query({ args: {}, returns: v.array(v.any()), handler: async (ctx) => ctx.db.query("messages").withIndex("by_channel", (q) => q.eq("channel", "x")).collect() });`).length === 0); + +ok("clean: JS array .filter (no q.field) is allowed", + rulesOf("convex/util.ts", `export const evens = (xs) => xs.filter(x => x % 2 === 0);`).length === 0); + +ok("clean: \"use node\" action file with crypto import", + rulesOf("convex/nodeStuff.ts", `"use node";\nimport crypto from "crypto";\nexport const x = action({ args: {}, handler: async () => crypto.randomUUID() });`).length === 0); + +ok("clean: httpAction-wrapped handler", + rulesOf("convex/http.ts", `http.route({ path: "/tasks", method: "GET", handler: httpAction(async (ctx, request) => new Response("ok")) });`).length === 0); + +ok("clean: legit index name in schema.ts", + rulesOf("convex/schema.ts", `defineTable({ a: v.string() }).index("by_a", ["a"])`).length === 0); + +ok("clean: non-convex path is skipped entirely", + rulesOf("app/page.ts", `export const delete = 1; import crypto from "crypto";`).length === 0); + +ok("clean: _generated is skipped", + rulesOf("convex/_generated/server.ts", `import crypto from "crypto";`).length === 0); + +ok("clean: .d.ts is skipped", + rulesOf("convex/foo.d.ts", `import crypto from "crypto";`).length === 0); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/plugins/convex/mcp/test.mjs b/plugins/convex/mcp/test.mjs index a39dbf9..8278b29 100644 --- a/plugins/convex/mcp/test.mjs +++ b/plugins/convex/mcp/test.mjs @@ -376,6 +376,101 @@ try { t.kill(); fs.rmSync(noTscProj, { recursive: true, force: true }); } + // ---- leg 6 (lint) scenarios (15–17) ------------------------------------ + // The typecheck leg also arms on these fixtures; CMD_OK keeps it quiet so + // only the lint leg can produce a non-quiet event. + + // 15. writing a convex/*.ts file with a hard-deny anti-pattern fires + // lint_error with the rule id and the fix text. + { + const lintProj = makeTypecheckProject(); + const t = startMcp({ CONVEX_MONITOR_TSC_CMD: CMD_OK, CONVEX_MONITOR_SPEC_URL: "http://127.0.0.1:1/monitors.json" }); + await t.handshake(); + const callP = t.rpc("tools/call", { name: "fix_errors_automatically", arguments: { projectDir: lintProj, timeoutMs: 10000 } }); + await sleep(500); // let the watcher arm + baseline scan + fs.writeFileSync( + path.join(lintProj, "convex", "messages.ts"), + `export const list = query({ args: {}, handler: async (ctx) => ctx.db.query("messages").filter(q => q.eq(q.field("channel"), "x")).collect() });\n`, + ); + const ev = JSON.parse((await callP).result.content[0].text); + ok("leg 6 fires lint_error on an anti-pattern write", ev.kind === "lint_error", JSON.stringify(ev)); + ok("lint_error names the rule and file", ev.rule === "db_filter" && ev.file === "convex/messages.ts", `${ev.rule} ${ev.file}`); + ok("lint_error carries the fix text", /withIndex/.test(ev.line || ""), ev.line || ""); + + // 16. dedupe: a second write with the SAME findings does not re-notify. + const quietP = t.rpc("tools/call", { name: "fix_errors_automatically", arguments: { projectDir: lintProj, timeoutMs: 3000 } }); + await sleep(300); + fs.appendFileSync(path.join(lintProj, "convex", "messages.ts"), "// cosmetic change, same finding\n"); + const quietEv = JSON.parse((await quietP).result.content[0].text); + ok("lint dedupe: unchanged findings do not re-notify (quiet)", quietEv.kind === "quiet", JSON.stringify(quietEv)); + t.kill(); + fs.rmSync(lintProj, { recursive: true, force: true }); + } + + // 17. baseline: violations that already exist when the leg FIRST arms are + // NOT replayed (parity with the Claude hook, which only fires on writes). + { + const baseProj = makeTypecheckProject(); + fs.writeFileSync( + path.join(baseProj, "convex", "old.ts"), + `export const remove = mutation(async (ctx) => {});\n`, // positional_syntax, pre-existing + ); + const t = startMcp({ CONVEX_MONITOR_TSC_CMD: CMD_OK, CONVEX_MONITOR_SPEC_URL: "http://127.0.0.1:1/monitors.json" }); + await t.handshake(); + const quietRes = await t.rpc("tools/call", { name: "fix_errors_automatically", arguments: { projectDir: baseProj, timeoutMs: 3000 } }); + const quiet = JSON.parse(quietRes.result.content[0].text); + ok("lint baseline: pre-existing violations are not replayed (quiet)", quiet.kind === "quiet", JSON.stringify(quiet)); + t.kill(); + fs.rmSync(baseProj, { recursive: true, force: true }); + } + + // ---- leg 5 (insights) scenarios (18–19) --------------------------------- + // Fake `npx convex insights` via the exec seam: emits one OCC line on the + // first run and adds a NEW read-limit line from the second run on (state via + // a counter file in the project dir — exec runs with cwd=projectDir). + const INSIGHTS_FAKE = fakeCmd( + "const fs=require('fs');let n=0;try{n=+fs.readFileSync('.count','utf8')}catch{};n++;fs.writeFileSync('.count',String(n));" + + "console.log('occRetried in messages:send conflict on table messages');" + + "if(n>1)console.log('documentsRead Limit exceeded in tasks:list');", + ); + + // 18. first poll = silent baseline; the next interval poll surfaces ONLY the + // new line as an `occ` event. + { + const insProj = fs.mkdtempSync(path.join(os.tmpdir(), "chefmon-ins-")); + fs.writeFileSync(path.join(insProj, ".env.local"), "CONVEX_DEPLOYMENT=dev:brave-fox-123\n"); + const t = startMcp({ + CONVEX_MONITOR_INSIGHTS_CMD: INSIGHTS_FAKE, + CONVEX_MONITOR_INSIGHTS_INTERVAL_MS: "1500", + CONVEX_MONITOR_SPEC_URL: "http://127.0.0.1:1/monitors.json", + }); + await t.handshake(); + const res = await t.rpc("tools/call", { name: "fix_errors_automatically", arguments: { projectDir: insProj, timeoutMs: 10000 } }); + const ev = JSON.parse(res.result.content[0].text); + ok("leg 5 fires occ on a NEW insight after the baseline poll", ev.kind === "occ", JSON.stringify(ev)); + ok("occ event carries only the new line (baseline diffed away)", /documentsRead Limit/.test(ev.line || "") && !/occRetried/.test(ev.line || ""), ev.line || ""); + ok("occ event carries the fix playbook note", /withIndex|aggregate|--details/.test(ev.note || ""), ev.note || ""); + t.kill(); + fs.rmSync(insProj, { recursive: true, force: true }); + } + + // 19. self-guard: anonymous/local deployment → leg never arms. + { + const anonProj = fs.mkdtempSync(path.join(os.tmpdir(), "chefmon-anon-")); + fs.writeFileSync(path.join(anonProj, ".env.local"), "CONVEX_DEPLOYMENT=anonymous:anonymous-fox\n"); + const t = startMcp({ + CONVEX_MONITOR_INSIGHTS_CMD: INSIGHTS_FAKE, + CONVEX_MONITOR_INSIGHTS_INTERVAL_MS: "500", + CONVEX_MONITOR_SPEC_URL: "http://127.0.0.1:1/monitors.json", + }); + await t.handshake(); + const quietRes = await t.rpc("tools/call", { name: "fix_errors_automatically", arguments: { projectDir: anonProj, timeoutMs: 3000 } }); + const quiet = JSON.parse(quietRes.result.content[0].text); + ok("insights self-guard: anonymous deployment → leg skipped (stderr)", t.stderr().includes("insights leg: no cloud CONVEX_DEPLOYMENT")); + ok("insights self-guard: anonymous deployment → no occ event (quiet)", quiet.kind === "quiet", JSON.stringify(quiet)); + t.kill(); + fs.rmSync(anonProj, { recursive: true, force: true }); + } } catch (e) { console.error("test threw:", e); fail++;