@homenshum/convex-mcp-nodebench 0.9.3 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -79,7 +79,7 @@ for (const tool of ALL_TOOLS) {
79
79
  // ── Server setup ────────────────────────────────────────────────────
80
80
  const server = new Server({
81
81
  name: "convex-mcp-nodebench",
82
- version: "0.9.3",
82
+ version: "0.9.5",
83
83
  }, {
84
84
  capabilities: {
85
85
  tools: {},
@@ -36,7 +36,10 @@ function auditActions(convexDir) {
36
36
  let actionsWithoutErrorHandling = 0;
37
37
  let actionCallingAction = 0;
38
38
  // Node APIs that require "use node" directive
39
- const nodeApis = /\b(require|__dirname|__filename|Buffer\.|process\.env|fs\.|path\.|crypto\.|child_process|net\.|http\.|https\.)\b/;
39
+ // NOTE: process.env is polyfilled by Convex in both runtimes — NOT a Node-only API
40
+ // NOTE: crypto.subtle / crypto.getRandomValues are Web Crypto API — available without "use node"
41
+ const nodeApis = /\b(require\s*\(|__dirname|__filename|Buffer\.|fs\.|path\.|child_process|net\.|http\.|https\.)\b/;
42
+ const nodeCryptoApis = /\bcrypto\.(create|randomBytes|pbkdf2|scrypt|sign|verify|getCiphers|getDiffieHellman|getHashes|Hash|Hmac|Cipher|Decipher)\b/;
40
43
  for (const filePath of files) {
41
44
  const content = readFileSync(filePath, "utf-8");
42
45
  const relativePath = filePath.replace(convexDir, "").replace(/^[\\/]/, "");
@@ -69,7 +72,10 @@ function auditActions(convexDir) {
69
72
  }
70
73
  const body = lines.slice(startLine, endLine).join("\n");
71
74
  // Check 1: ctx.db access in action (FATAL — not allowed)
72
- if (/ctx\.db\.(get|query|insert|patch|replace|delete)\s*\(/.test(body)) {
75
+ // BUT: skip if ctx.db is inside an inline ctx.runMutation/ctx.runQuery callback
76
+ // e.g. ctx.runMutation(async (ctx) => { ctx.db.patch(...) }) — the inner ctx is a mutation context
77
+ const hasInlineCallback = /ctx\.run(Mutation|Query)\s*\(\s*async\s*\(/.test(body);
78
+ if (/ctx\.db\.(get|query|insert|patch|replace|delete)\s*\(/.test(body) && !hasInlineCallback) {
73
79
  actionsWithDbAccess++;
74
80
  issues.push({
75
81
  severity: "critical",
@@ -80,7 +86,7 @@ function auditActions(convexDir) {
80
86
  });
81
87
  }
82
88
  // Check 2: Node API usage without "use node"
83
- if (!hasUseNode && nodeApis.test(body)) {
89
+ if (!hasUseNode && (nodeApis.test(body) || nodeCryptoApis.test(body))) {
84
90
  actionsWithoutNodeDirective++;
85
91
  issues.push({
86
92
  severity: "critical",
@@ -70,7 +70,9 @@ function auditAuthorization(convexDir) {
70
70
  }
71
71
  const body = lines.slice(i, endLine).join("\n");
72
72
  const hasAuthCheck = /ctx\.auth\.getUserIdentity\s*\(\s*\)/.test(body) ||
73
- /getUserIdentity/.test(body);
73
+ /getUserIdentity/.test(body) ||
74
+ /getAuthUserId/.test(body) ||
75
+ /getAuthSessionId/.test(body);
74
76
  const hasDbWrite = dbWriteOps.test(body);
75
77
  const isSensitiveName = writeSensitive.test(funcName);
76
78
  if (hasAuthCheck) {
@@ -79,8 +81,8 @@ function auditAuthorization(convexDir) {
79
81
  const identityAssign = body.match(/(?:const|let)\s+(\w+)\s*=\s*await\s+ctx\.auth\.getUserIdentity\s*\(\s*\)/);
80
82
  if (identityAssign) {
81
83
  const varName = identityAssign[1];
82
- // Look for null check: if (!var), if (var === null), if (var == null), if (!var)
83
- const hasNullCheck = new RegExp(`if\\s*\\(\\s*!${varName}\\b|if\\s*\\(\\s*${varName}\\s*===?\\s*null|if\\s*\\(\\s*!${varName}\\s*\\)`).test(body);
84
+ // Recognize: if (!var), if (var === null), if (var), var &&, var ?, throw on !var
85
+ const hasNullCheck = new RegExp(`if\\s*\\(\\s*!${varName}\\b|if\\s*\\(\\s*${varName}\\s*===?\\s*null|if\\s*\\(\\s*${varName}\\s*\\)|${varName}\\s*&&|${varName}\\s*\\?|throw.*!${varName}|${varName}\\s*!==?\\s*null`).test(body);
84
86
  if (!hasNullCheck) {
85
87
  uncheckedIdentity++;
86
88
  issues.push({
@@ -92,6 +94,23 @@ function auditAuthorization(convexDir) {
92
94
  });
93
95
  }
94
96
  }
97
+ // Check: getAuthUserId called but return not null-checked
98
+ const authUserAssign = body.match(/(?:const|let)\s+(\w+)\s*=\s*await\s+getAuthUserId\s*\(/);
99
+ if (authUserAssign) {
100
+ const varName = authUserAssign[1];
101
+ // Recognize: if (!var), if (var === null), if (var), var &&, var ?, throw on !var
102
+ const hasNullCheck = new RegExp(`if\\s*\\(\\s*!${varName}\\b|if\\s*\\(\\s*${varName}\\s*===?\\s*null|if\\s*\\(\\s*${varName}\\s*\\)|${varName}\\s*&&|${varName}\\s*\\?|throw.*!${varName}|${varName}\\s*!==?\\s*null`).test(body);
103
+ if (!hasNullCheck) {
104
+ uncheckedIdentity++;
105
+ issues.push({
106
+ severity: "critical",
107
+ location: `${relativePath}:${i + 1}`,
108
+ functionName: funcName,
109
+ message: `${ft} "${funcName}" calls getAuthUserId() but doesn't check for null. Unauthenticated users will get null userId.`,
110
+ fix: `Add: if (!${varName}) { throw new Error("Not authenticated"); }`,
111
+ });
112
+ }
113
+ }
95
114
  }
96
115
  else {
97
116
  withoutAuth++;
@@ -45,16 +45,18 @@ function auditPagination(convexDir) {
45
45
  functionsUsingPagination.add(relativePath);
46
46
  }
47
47
  }
48
- // Check: functions using paginate but missing paginationOptsValidator in args
48
+ // Check: public queries using paginate but missing paginationOptsValidator in args
49
+ // NOTE: internalQuery doesn't need paginationOptsValidator — it's not client-callable
49
50
  const funcPattern = /export\s+(?:const\s+(\w+)\s*=|default)\s+(query|internalQuery)\s*\(/g;
50
51
  let m;
51
52
  while ((m = funcPattern.exec(content)) !== null) {
52
53
  const funcName = m[1] || "default";
54
+ const funcType = m[2];
53
55
  const startLine = content.slice(0, m.index).split("\n").length - 1;
54
56
  const chunk = lines.slice(startLine, Math.min(startLine + 30, lines.length)).join("\n");
55
57
  if (/\.paginate\s*\(/.test(chunk)) {
56
- // Check for paginationOptsValidator
57
- if (!/paginationOptsValidator/.test(chunk) && !/paginationOpts/.test(chunk)) {
58
+ // Only flag public queries — internalQuery can use paginate with hardcoded opts
59
+ if (funcType === "query" && !/paginationOptsValidator/.test(chunk) && !/paginationOpts/.test(chunk)) {
58
60
  missingPaginationOptsValidator++;
59
61
  issues.push({
60
62
  severity: "critical",
@@ -77,17 +77,18 @@ function runQualityGate(projectDir, thresholds) {
77
77
  }
78
78
  catch { /* skip */ }
79
79
  }
80
- // Check 4: Type safety (as any casts)
80
+ // Check 4: Type safety (as any casts) — exclude test/eval files
81
81
  const typeSafety = getLatest("type_safety");
82
82
  if (typeSafety) {
83
83
  try {
84
84
  const issues = JSON.parse(typeSafety.issues_json);
85
- const asAnyIssues = Array.isArray(issues)
86
- ? issues.filter((i) => i.message?.includes("as any")).length
87
- : 0;
85
+ // Exclude test, eval, benchmark, and fixture files from production quality gate
86
+ const testEvalPattern = /test|__tests__|spec|\.test\.|\.spec\.|fixtures|mocks|evaluation|liveEval|liveApiSmoke|benchmark|calibration|harness|quickTest|comprehensiveTest|comprehensiveEval/i;
88
87
  // Each as-any issue represents a FILE, count from message for actual number
89
88
  const actualCasts = Array.isArray(issues)
90
89
  ? issues.reduce((sum, i) => {
90
+ if (testEvalPattern.test(i.location ?? ""))
91
+ return sum; // skip test/eval files
91
92
  const countMatch = i.message?.match(/(\d+)\s+`as any`/);
92
93
  return sum + (countMatch ? parseInt(countMatch[1], 10) : 0);
93
94
  }, 0)
@@ -102,13 +103,13 @@ function runQualityGate(projectDir, thresholds) {
102
103
  }
103
104
  catch { /* skip */ }
104
105
  }
105
- // Check 5: Unbounded collects
106
+ // Check 5: Unbounded collects (only warning-level — indexed collects are downgraded to info)
106
107
  const queryEfficiency = getLatest("query_efficiency");
107
108
  if (queryEfficiency) {
108
109
  try {
109
110
  const issues = JSON.parse(queryEfficiency.issues_json);
110
111
  const unbounded = Array.isArray(issues)
111
- ? issues.filter((i) => i.message?.includes(".collect()")).length
112
+ ? issues.filter((i) => i.severity === "warning" && i.message?.includes(".collect()")).length
112
113
  : 0;
113
114
  checks.push({
114
115
  metric: "unbounded_collects",
@@ -63,13 +63,16 @@ function auditQueryEfficiency(convexDir) {
63
63
  const chain = lines.slice(chainStart, i + 1).join("\n");
64
64
  if (!/\.take\s*\(/.test(chain) && !/\.paginate\s*\(/.test(chain)) {
65
65
  collectWithoutLimit++;
66
- // Check if it's a bounded table or could be large
67
66
  const tableMatch = chain.match(/\.query\s*\(\s*["'](\w+)["']\s*\)/);
67
+ // Indexed queries are bounded by the index range — lower severity
68
+ const hasIndex = /\.withIndex\s*\(/.test(chain);
68
69
  issues.push({
69
- severity: "warning",
70
+ severity: hasIndex ? "info" : "warning",
70
71
  location: `${relativePath}:${i + 1}`,
71
72
  message: `.collect() without .take() limit${tableMatch ? ` on table "${tableMatch[1]}"` : ""}. Could return entire table.`,
72
- fix: "Add .take(N) before .collect(), or use .paginate() for large result sets",
73
+ fix: hasIndex
74
+ ? "Consider adding .take(N) for predictable result sizes, even with index filtering"
75
+ : "Add .take(N) before .collect(), or use .paginate() for large result sets",
73
76
  });
74
77
  }
75
78
  }
@@ -75,7 +75,7 @@ function buildSarif(projectDir, auditTypes, limit) {
75
75
  tool: {
76
76
  driver: {
77
77
  name: "convex-mcp-nodebench",
78
- version: "0.9.3",
78
+ version: "0.9.5",
79
79
  informationUri: "https://www.npmjs.com/package/@homenshum/convex-mcp-nodebench",
80
80
  rules: [...rulesMap.values()],
81
81
  },
@@ -18,10 +18,10 @@ function collectTsFiles(dir) {
18
18
  const entries = readdirSync(dir, { withFileTypes: true });
19
19
  for (const entry of entries) {
20
20
  const full = join(dir, entry.name);
21
- if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== "_generated") {
21
+ if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== "_generated" && !entry.name.startsWith("_type")) {
22
22
  results.push(...collectTsFiles(full));
23
23
  }
24
- else if (entry.isFile() && entry.name.endsWith(".ts")) {
24
+ else if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
25
25
  results.push(full);
26
26
  }
27
27
  }
@@ -44,7 +44,13 @@ function auditVectorSearch(convexDir) {
44
44
  if (existsSync(schemaPath)) {
45
45
  const schema = readFileSync(schemaPath, "utf-8");
46
46
  const lines = schema.split("\n");
47
+ // Track current table context: scan for "tableName: defineTable(" or "tableName = defineTable("
48
+ let currentTable = "";
47
49
  for (let i = 0; i < lines.length; i++) {
50
+ const tableMatch = lines[i].match(/(\w+)\s*[:=]\s*defineTable\s*\(/);
51
+ if (tableMatch) {
52
+ currentTable = tableMatch[1];
53
+ }
48
54
  // Match .vectorIndex("name", { ... })
49
55
  const viMatch = lines[i].match(/\.vectorIndex\s*\(\s*["']([^"']+)["']/);
50
56
  if (viMatch) {
@@ -56,7 +62,7 @@ function auditVectorSearch(convexDir) {
56
62
  const filters = filterMatch
57
63
  ? filterMatch[1].match(/["']([^"']+)["']/g)?.map(s => s.replace(/["']/g, "")) ?? []
58
64
  : [];
59
- vectorIndexes.push({ table: viMatch[1], dimensions: dims, filterFields: filters, line: i + 1 });
65
+ vectorIndexes.push({ tableName: currentTable || viMatch[1], indexName: viMatch[1], dimensions: dims, filterFields: filters, line: i + 1 });
60
66
  // Check: uncommon dimension size
61
67
  if (dims > 0 && !KNOWN_DIMENSIONS[dims]) {
62
68
  const nearest = Object.keys(KNOWN_DIMENSIONS)
@@ -99,18 +105,19 @@ function auditVectorSearch(convexDir) {
99
105
  const relativePath = filePath.replace(convexDir, "").replace(/^[\\/]/, "");
100
106
  const lines = content.split("\n");
101
107
  for (let i = 0; i < lines.length; i++) {
108
+ // vectorSearch("tableName", "indexName", options) — first arg is TABLE name
102
109
  const vsMatch = lines[i].match(/\.vectorSearch\s*\(\s*["']([^"']+)["']/);
103
110
  if (vsMatch) {
104
111
  vectorSearchCallCount++;
105
- const indexName = vsMatch[1];
106
- // Check if the referenced index exists
107
- const matchingIdx = vectorIndexes.find(vi => vi.table === indexName);
112
+ const tableName = vsMatch[1];
113
+ // Check if the referenced table has any vector index
114
+ const matchingIdx = vectorIndexes.find(vi => vi.tableName === tableName);
108
115
  if (!matchingIdx && vectorIndexes.length > 0) {
109
116
  issues.push({
110
117
  severity: "critical",
111
118
  location: `${relativePath}:${i + 1}`,
112
- message: `vectorSearch references index "${indexName}" which is not defined in schema.ts.`,
113
- fix: `Add a .vectorIndex("${indexName}", { ... }) to the appropriate table in schema.ts`,
119
+ message: `vectorSearch references table "${tableName}" which has no vectorIndex defined in schema.ts.`,
120
+ fix: `Add a .vectorIndex("by_embedding", { ... }) to the "${tableName}" table in schema.ts`,
114
121
  });
115
122
  }
116
123
  // Check: no filter parameter when filterFields exist
@@ -119,7 +126,7 @@ function auditVectorSearch(convexDir) {
119
126
  issues.push({
120
127
  severity: "info",
121
128
  location: `${relativePath}:${i + 1}`,
122
- message: `vectorSearch on "${indexName}" doesn't use filter — available filterFields: ${matchingIdx.filterFields.join(", ")}`,
129
+ message: `vectorSearch on "${tableName}" doesn't use filter — available filterFields: ${matchingIdx.filterFields.join(", ")}`,
123
130
  fix: "Add filter parameter to narrow results and improve performance",
124
131
  });
125
132
  }
@@ -131,7 +138,7 @@ function auditVectorSearch(convexDir) {
131
138
  issues.push({
132
139
  severity: "critical",
133
140
  location: `${relativePath}:${i + 1}`,
134
- message: `Vector dimensions mismatch: code uses ${codeDims} but schema defines ${matchingIdx.dimensions} for index "${indexName}".`,
141
+ message: `Vector dimensions mismatch: code uses ${codeDims} but schema defines ${matchingIdx.dimensions} for table "${tableName}".`,
135
142
  fix: `Ensure embedding model output (${codeDims}) matches schema dimensions (${matchingIdx.dimensions})`,
136
143
  });
137
144
  }
@@ -139,7 +146,7 @@ function auditVectorSearch(convexDir) {
139
146
  }
140
147
  }
141
148
  }
142
- const tablesWithVectors = [...new Set(vectorIndexes.map(vi => vi.table))];
149
+ const tablesWithVectors = [...new Set(vectorIndexes.map(vi => vi.tableName))];
143
150
  const dimensions = [...new Set(vectorIndexes.map(vi => vi.dimensions).filter(d => d > 0))];
144
151
  return {
145
152
  issues,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homenshum/convex-mcp-nodebench",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "description": "Convex-specific MCP server applying NodeBench self-instruct diligence patterns to Convex development. Schema audit, function compliance, deployment gates, persistent gotcha DB, and methodology guidance. Complements Context7 (raw docs) and official Convex MCP (deployment introspection) with structured verification workflows.",
5
5
  "type": "module",
6
6
  "bin": {