@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 +1 -1
- package/dist/tools/actionAuditTools.js +9 -3
- package/dist/tools/authorizationTools.js +22 -3
- package/dist/tools/paginationTools.js +5 -3
- package/dist/tools/qualityGateTools.js +7 -6
- package/dist/tools/queryEfficiencyTools.js +6 -3
- package/dist/tools/reportingTools.js +1 -1
- package/dist/tools/typeSafetyTools.js +2 -2
- package/dist/tools/vectorSearchTools.js +16 -9
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
//
|
|
83
|
-
const hasNullCheck = new RegExp(`if\\s*\\(\\s*!${varName}\\b|if\\s*\\(\\s*${varName}\\s*===?\\s*null|if\\s*\\(\\s
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
86
|
-
|
|
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:
|
|
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.
|
|
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({
|
|
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
|
|
106
|
-
// Check if the referenced index
|
|
107
|
-
const matchingIdx = vectorIndexes.find(vi => vi.
|
|
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
|
|
113
|
-
fix: `Add a .vectorIndex("
|
|
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 "${
|
|
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
|
|
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.
|
|
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
|
+
"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": {
|