From d89c46a3475050d5efc3077f0c67c65fc58012e3 Mon Sep 17 00:00:00 2001 From: Caleb Date: Mon, 20 Jul 2026 17:14:13 +0300 Subject: [PATCH 1/3] feat: replace heuristic Big-O analyzer with structured complexity util --- CHANGELOG.md | 6 + src/commands/code/complexity.ts | 164 +------------ src/utils/analyzeComplexity.ts | 413 ++++++++++++++++++++++++++++++++ 3 files changed, 431 insertions(+), 152 deletions(-) create mode 100644 src/utils/analyzeComplexity.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ce9ef27..534af28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## `v1.2.0` - 2026-07-20 + +### Changed + +- Replaced the previous score/regex-based estimator with a dedicated `analyzeComplexity` utility that properly tracks loop nesting depth, classifies recursion (linear / logarithmic / exponential / factorial / memoized), and produces structured indicators + reasoning. + ## `v1.1.1` - 2026-07-13 ### Fixed diff --git a/src/commands/code/complexity.ts b/src/commands/code/complexity.ts index 0673398..6ca5f61 100644 --- a/src/commands/code/complexity.ts +++ b/src/commands/code/complexity.ts @@ -1,5 +1,6 @@ import { EmbedBuilder } from "discord.js"; import type { CommandCallbackOpts } from "../../types/command.ts"; +import { analyzeComplexity } from "../../utils/analyzeComplexity.ts"; import { parseCodeBlock, parseCodeCommandInput, @@ -17,7 +18,7 @@ export default { args, ); - let code; + let code: string | undefined; if (parsedBlock) { code = parsedBlock.code; @@ -76,8 +77,6 @@ export default { } const safe = (input: string, limit = 1000) => { - // if (typeof input !== "string") input = String(input); - return input.length > limit ? input.slice(0, limit) + "\n... (truncated)" : input; @@ -90,154 +89,14 @@ export default { return preview.length > limit ? preview.slice(0, limit) + "..." : preview; }; - const stripDecorators = (input: string) => - input - .replace(/\/\/.*$/gm, "") - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/(["'`])(?:\\.|(?!\1).)*\1/g, ""); - try { - if (!code?.trim()) { + if (!code.trim()) { return message.reply( "Provide code in a codeblock.\nExample:\n```js\nfor(let i=0;i (loopDetected = true), - }, - { - regex: /for\s*\([^)]*\)\s*\{[\s\S]*?for\s*\([^)]*\)\s*\{/g, - value: 3, - label: "Nested loops detected", - }, - { - regex: /for\s*\([^)]*\)\s*\{[\s\S]*?while\s*\(/g, - value: 3, - label: "Mixed nested iteration detected", - }, - { - regex: - /for\s*\([^)]*\)\s*\{[\s\S]*?for\s*\([^)]*\)\s*\{[\s\S]*?for\s*\(/g, - value: 4, - label: "Triple nested loops detected", - flag: () => (tripleNested = true), - }, - { - regex: /\.sort\s*\(/g, - value: 2, - label: "Sorting operation detected", - flag: () => (sortDetected = true), - }, - { - regex: - /(\/=|>>=|<<=|\*= *0\.5|Math\.log|Math\.sqrt|Math\.floor|\blog2\b|\blog10\b|n\s*\/\s*2)\b/g, - value: 0.75, - label: "Logarithmic pattern detected", - flag: () => (logarithmicPattern = true), - }, - { - regex: - /\b(?:map|filter|reduce|some|every|find|includes|forEach)\s*\(/g, - value: 1, - label: "Collection iteration helper detected", - flag: () => (collectionIteration = true), - }, - ]; - - for (const pattern of patterns) { - if (pattern.regex.test(strippedCode)) { - analysis.add(pattern.label); - score += pattern.value; - if (pattern.flag) pattern.flag(); - } - } - - const recursionPattern = - /([a-zA-Z_$][\w$]*)\s*\([^)]*\)\s*\{([\s\S]*?)\}/g; - for (const match of strippedCode.matchAll(recursionPattern)) { - const functionName = match[1]; - const functionBody = match[2]; - const recursiveCalls = ( - (functionBody && - functionBody.match(new RegExp(`\\b${functionName}\\s*\\(`, "g"))) || - [] - ).length; - - if (recursiveCalls >= 1) { - recursionDetected = true; - analysis.add("Recursive call pattern detected"); - score += 2; - } - - if (recursiveCalls > 1) { - exponentialRecursion = true; - analysis.add("Multiple recursive calls detected"); - score += 3; - } - } - - if (analysis.size === 0) { - analysis.add("No strong complexity indicators detected"); - } - - const complexity = (() => { - if (exponentialRecursion) return "O(2ⁿ)"; - if (tripleNested) return "O(n³)"; - if ( - analysis.has("Nested loops detected") || - analysis.has("Mixed nested iteration detected") - ) - return "O(n²)"; - if (sortDetected) return "O(n log n)"; - if (logarithmicPattern && !loopDetected && !recursionDetected) - return "O(log n)"; - if (recursionDetected) return "O(n)"; - if (loopDetected || collectionIteration) return "O(n)"; - return "O(1)"; - })(); - - const confidence = score >= 5 ? "High" : score >= 2 ? "Medium" : "Low"; - const reasoning = []; - if (exponentialRecursion) - reasoning.push( - "Multiple recursive calls often indicate exponential growth.", - ); - else if (tripleNested) - reasoning.push("Triple nested loops strongly suggest cubic time."); - else if ( - analysis.has("Nested loops detected") || - analysis.has("Mixed nested iteration detected") - ) - reasoning.push("Nested iteration is the primary driver of complexity."); - else if (sortDetected) - reasoning.push("Sorting operations are typically O(n log n)."); - else if (logarithmicPattern) - reasoning.push( - "Logarithmic operations can indicate O(log n) behavior.", - ); - else if (recursionDetected) - reasoning.push( - "Single recursive call patterns often map to linear recursion.", - ); - else if (loopDetected || collectionIteration) - reasoning.push("Linear iteration is the main complexity indicator."); - else reasoning.push("No clear complexity-driving patterns found."); + const result = analyzeComplexity(code); const embed = new EmbedBuilder() .setTitle("Big-O Complexity Estimation") @@ -245,24 +104,25 @@ export default { .addFields( { name: "Estimated Complexity", - value: `\`${complexity}\``, + value: `\`${result.complexity}\``, inline: true, }, { name: "Confidence", - value: confidence, + value: result.confidence, inline: true, }, { name: "Key Indicators", - value: Array.from(analysis) - .slice(0, 5) - .map((x) => `• ${x}`) - .join("\n"), + value: + result.indicators + .slice(0, 5) + .map((x) => `• ${x}`) + .join("\n") || "• None", }, { name: "Reasoning", - value: reasoning.join(" "), + value: result.reasoning.join(" ") || "No additional reasoning.", }, { name: "Code Preview", diff --git a/src/utils/analyzeComplexity.ts b/src/utils/analyzeComplexity.ts new file mode 100644 index 0000000..d9cf172 --- /dev/null +++ b/src/utils/analyzeComplexity.ts @@ -0,0 +1,413 @@ +// ranking +const RANK = { + "O(1)": 0, + "O(log n)": 1, + "O(n)": 2, + "O(n log n)": 3, + "O(n²)": 4, + "O(n³)": 5, + "O(n^k)": 6, + "O(2ⁿ)": 7, + "O(n!)": 8, +} as const; + +type Complexity = keyof typeof RANK; + +function worse(a: Complexity, b: Complexity): Complexity { + return RANK[a] >= RANK[b] ? a : b; +} + +// prep +function stripDecorators(input: string): string { + return input + .replace(/\/\/.*$/gm, (m) => " ".repeat(m.length)) + .replace(/\/\*[\s\S]*?\*\//g, (m) => " ".repeat(m.length)) + .replace(/(["'`])(?:\\.|(?!\1).)*\1/g, (m) => " ".repeat(m.length)); +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +// find index of the closing brace matching the "{" at openIndex +function matchBrace(str: string, openIndex: number): number { + let depth = 0; + for (let i = openIndex; i < str.length; i++) { + const ch = str.charAt(i); + if (ch === "{") depth++; + else if (ch === "}") { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +// find index of the closing paren matching the "(" at openIndex +function matchParen(str: string, openIndex: number): number { + let depth = 0; + for (let i = openIndex; i < str.length; i++) { + const ch = str.charAt(i); + if (ch === "(") depth++; + else if (ch === ")") { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +// loop-depth profile +const LOOP_HEADER_RE = new RegExp( + [ + String.raw`\bfor\s*\([^{}]*\)\s*$`, + String.raw`\bwhile\s*\([^{}]*\)\s*$`, + String.raw`\bdo\s*$`, + String.raw`\.(?:forEach|map|filter|reduce|reduceRight|some|every|find|findIndex|flatMap)\s*\(\s*(?:async\s*)?(?:function\b[^{}]*|\([^{}]*\)\s*=>|[a-zA-Z_$][\w$]*\s*=>)\s*$`, + ].join("|"), +); + +interface LoopProfile { + depthAt: number[]; + maxDepth: number; +} + +function loopDepthProfile(str: string): LoopProfile { + const depthAt: number[] = new Array(str.length + 1).fill(0); + const stack: boolean[] = []; + let current = 0; + let max = 0; + for (let i = 0; i < str.length; i++) { + const ch = str.charAt(i); + if (ch === "{") { + const before = str.slice(Math.max(0, i - 220), i); + const isLoop = LOOP_HEADER_RE.test(before); + stack.push(isLoop); + if (isLoop) { + current++; + if (current > max) max = current; + } + } else if (ch === "}") { + const wasLoop = stack.pop(); + if (wasLoop) current--; + } + depthAt[i + 1] = current; + } + return { depthAt, maxDepth: max }; +} + +// brace-less fallback: catches `for (...) doThing();` with no block +const BARE_LOOP_RE = /\b(?:for|while)\s*\([^)]*\)\s*(?!\s*\{)/; + +interface FunctionInfo { + name: string; + bodyStart: number; + bodyEnd: number; + headerIndex: number; +} + +// A discovered function: { name, bodyStart, bodyEnd, headerIndex } +function findFunctions(code: string): FunctionInfo[] { + const found: FunctionInfo[] = []; + + const FUNC_LIKE_RE = + /\b(?:function\s*\*?\s*)?([a-zA-Z_$][\w$]*)\s*\(([^()]*)\)\s*(?::\s*[^{=;]+)?\{/g; + const CONTROL_KEYWORDS = new Set([ + "if", + "for", + "while", + "switch", + "catch", + "function", + "return", + "else", + "do", + "try", + "finally", + "typeof", + "new", + "in", + "of", + "await", + "yield", + "delete", + "void", + "instanceof", + "constructor", + ]); + + let m: RegExpExecArray | null; + while ((m = FUNC_LIKE_RE.exec(code))) { + const name = m[1]; + if (name === undefined) continue; + if (CONTROL_KEYWORDS.has(name)) continue; + const fullMatch = m[0]; + if (fullMatch === undefined) continue; + const braceIdx = m.index + fullMatch.length - 1; // the "{" itself + const bodyEnd = matchBrace(code, braceIdx); + if (bodyEnd === -1) continue; + found.push({ + name, + headerIndex: braceIdx, + bodyStart: braceIdx + 1, + bodyEnd, + }); + } + + const ARROW_RE = + /\b(?:const|let|var)\s+([a-zA-Z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:\([^()]*\)|[a-zA-Z_$][\w$]*)\s*(?::\s*[^=]+)?=>\s*/g; + + while ((m = ARROW_RE.exec(code))) { + const name = m[1]; + if (name === undefined) continue; + const fullMatch = m[0]; + if (fullMatch === undefined) continue; + const afterArrow = m.index + fullMatch.length; + // skip whitespace + let i = afterArrow; + while (i < code.length && /\s/.test(code.charAt(i))) i++; + if (code.charAt(i) === "{") { + const bodyEnd = matchBrace(code, i); + if (bodyEnd === -1) continue; + found.push({ name, headerIndex: i, bodyStart: i + 1, bodyEnd }); + } else { + // expression body: scan until a top-level `;` or `,` (depth 0 relative to start) + let depth = 0; + let j = i; + for (; j < code.length; j++) { + const c = code.charAt(j); + if (c === "(" || c === "[" || c === "{") depth++; + else if (c === ")" || c === "]" || c === "}") { + if (depth === 0) break; + depth--; + } else if ((c === ";" || c === ",") && depth === 0) { + break; + } + } + found.push({ name, headerIndex: i, bodyStart: i, bodyEnd: j }); + } + } + + return found; +} + +// recursion classification per function +const HALVING_RE = + /\/\s*2\b|>>>?\s*1\b|Math\.floor\([^()]*\/\s*2\)|Math\.ceil\([^()]*\/\s*2\)/; +const DECREMENT_RE = /[a-zA-Z_$][\w$]*\s*-\s*\d+\b|--/; +const MEMO_RE = + /\b(?:memo|cache|dp|seen|visited)\b\s*(?:\[|\.\s*(?:get|has)\s*\()/i; + +interface RecursionResult { + complexity: Complexity; + note: string; + callCount: number; + memoized: boolean; + halvingAll: boolean; + callInsideLoop: boolean; +} + +function analyzeRecursion( + code: string, + fn: FunctionInfo, + loopProfile: LoopProfile, +): RecursionResult | null { + const body = code.slice(fn.bodyStart, fn.bodyEnd); + const nameRe = new RegExp(`\\b${escapeRegExp(fn.name)}\\s*\\(`, "g"); + + const callSites: { absIndex: number; args: string }[] = []; + let cm: RegExpExecArray | null; + while ((cm = nameRe.exec(body))) { + const fullMatch = cm[0]; + if (fullMatch === undefined) continue; + const absIndex = fn.bodyStart + cm.index; + const parenOpen = fn.bodyStart + cm.index + fullMatch.length - 1; + const parenClose = matchParen(code, parenOpen); + const args = parenClose === -1 ? "" : code.slice(parenOpen + 1, parenClose); + callSites.push({ absIndex, args }); + } + + if (callSites.length === 0) return null; + + const halvingAll = callSites.every((c) => HALVING_RE.test(c.args)); + const anyDecrement = callSites.some((c) => DECREMENT_RE.test(c.args)); + + const baseDepth = loopProfile.depthAt[fn.headerIndex] ?? 0; + const callInsideLoop = callSites.some((c) => { + const relDepth = (loopProfile.depthAt[c.absIndex] ?? 0) - baseDepth; + return relDepth > 0; + }); + + const searchWindowStart = Math.max(0, fn.headerIndex - 300); + const memoized = MEMO_RE.test(code.slice(searchWindowStart, fn.bodyEnd)); + + let complexity: Complexity; + let note: string; + + if (callSites.length === 1 && callInsideLoop) { + complexity = "O(n!)"; + note = `Recursive call to "${fn.name}" occurs inside a loop within the same function (backtracking/permutation pattern)`; + } else if (callSites.length >= 2 && callInsideLoop) { + complexity = "O(n!)"; + note = `Multiple recursive calls to "${fn.name}", at least one nested inside a loop (backtracking pattern)`; + } else if (callSites.length === 1) { + const firstCall = callSites[0]; + if (firstCall !== undefined && HALVING_RE.test(firstCall.args)) { + complexity = "O(log n)"; + note = `Single recursive call to "${fn.name}" shrinks input by half each time`; + } else { + complexity = "O(n)"; + note = `Single recursive call to "${fn.name}" (linear recursion)`; + } + } else { + // 2+ self-calls + if (halvingAll) { + complexity = "O(n log n)"; + note = `Multiple recursive calls to "${fn.name}" each operate on a halved input (divide-and-conquer)`; + } else if (memoized) { + complexity = "O(n)"; + note = `Multiple recursive calls to "${fn.name}" detected, but memoization (cache/memo/dp) caps repeated work`; + } else { + complexity = "O(2ⁿ)"; + note = `Multiple recursive calls to "${fn.name}" without memoization (exponential branching)`; + } + } + + return { + complexity, + note, + callCount: callSites.length, + memoized, + halvingAll, + callInsideLoop, + }; +} + +// main +interface AnalysisResult { + complexity: Complexity; + confidence: "Low" | "Medium" | "High"; + indicators: string[]; + reasoning: string[]; + _debug: { + loopComplexity: Complexity; + recComplexity: Complexity; + maxDepth: number; + }; +} + +export function analyzeComplexity(rawCode: string): AnalysisResult { + const code = stripDecorators(rawCode); + const indicators: string[] = []; + const reasoning: string[] = []; + + // loops + const profile = loopDepthProfile(code); + let loopComplexity: Complexity = "O(1)"; + if (profile.maxDepth >= 3) { + loopComplexity = profile.maxDepth === 3 ? "O(n³)" : "O(n^k)"; + indicators.push(`${profile.maxDepth}-deep nested loop detected`); + } else if (profile.maxDepth === 2) { + loopComplexity = "O(n²)"; + indicators.push("Nested loop (depth 2) detected"); + } else if (profile.maxDepth === 1) { + loopComplexity = "O(n)"; + indicators.push("Single loop detected"); + } else if (BARE_LOOP_RE.test(code)) { + loopComplexity = "O(n)"; + indicators.push("Brace-less loop detected"); + } + + const sortDetected = /\.sort\s*\(/.test(code); + if (sortDetected) { + indicators.push("Sorting operation detected (.sort)"); + loopComplexity = worse(loopComplexity, "O(n log n)"); + } + + const collectionHelperDetected = + /\b(?:map|filter|reduce|reduceRight|some|every|find|findIndex|includes|forEach|flatMap)\s*\(/.test( + code, + ); + if (collectionHelperDetected && loopComplexity === "O(1)") { + loopComplexity = "O(n)"; + indicators.push("Collection iteration helper detected"); + } + + const logPatternDetected = + /(?:\/=|>>=|<<=|\*=\s*0\.5|Math\.log|Math\.sqrt|\blog2\b|\blog10\b)/.test( + code, + ) && loopComplexity === "O(1)"; + if (logPatternDetected) { + loopComplexity = "O(log n)"; + indicators.push( + "Logarithmic pattern detected (no loop/recursion driving it)", + ); + } + + // recursion + const functions = findFunctions(code); + let recComplexity: Complexity = "O(1)"; + let bestRecNote: string | null = null; + let recursionSeen = false; + let memoSeen = false; + + for (const fn of functions) { + const result = analyzeRecursion(code, fn, profile); + if (!result) continue; + recursionSeen = true; + if (result.memoized) memoSeen = true; + if (RANK[result.complexity] > RANK[recComplexity]) { + recComplexity = result.complexity; + bestRecNote = result.note; + } + } + + if (recursionSeen) { + indicators.push("Recursive call pattern detected"); + if (memoSeen) + indicators.push("Memoization pattern detected (cache/memo/dp)"); + } + if (bestRecNote) reasoning.push(bestRecNote); + + const complexity = worse(loopComplexity, recComplexity); + + if (indicators.length === 0) { + indicators.push("No strong complexity indicators detected"); + } + + if (reasoning.length === 0) { + if (profile.maxDepth >= 2) { + reasoning.push( + `Nested loops (depth ${profile.maxDepth}) are the primary complexity driver.`, + ); + } else if (sortDetected) { + reasoning.push("Sorting operations are typically O(n log n)."); + } else if (logPatternDetected) { + reasoning.push("Logarithmic operations suggest O(log n) behavior."); + } else if (loopComplexity === "O(n)") { + reasoning.push("Linear iteration is the main complexity indicator."); + } else { + reasoning.push("No clear complexity-driving patterns found."); + } + } + + // confidence + let confidence: "Low" | "Medium" | "High" = "Low"; + if ( + recComplexity === "O(2ⁿ)" || + recComplexity === "O(n!)" || + profile.maxDepth >= 2 + ) { + confidence = "High"; + } else if (recursionSeen || loopComplexity !== "O(1)") { + confidence = "Medium"; + } + + return { + complexity, + confidence, + indicators, + reasoning, + _debug: { loopComplexity, recComplexity, maxDepth: profile.maxDepth }, + }; +} From 7244c30c00f1c454838687cda1f61ce97af56560 Mon Sep 17 00:00:00 2001 From: Caleb Date: Mon, 20 Jul 2026 17:19:00 +0300 Subject: [PATCH 2/3] chore: update changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 534af28..52923b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,13 @@ ### Changed -- Replaced the previous score/regex-based estimator with a dedicated `analyzeComplexity` utility that properly tracks loop nesting depth, classifies recursion (linear / logarithmic / exponential / factorial / memoized), and produces structured indicators + reasoning. +- Replaced the previous score/regex-based estimator with a dedicated `analyzeComplexity` utility that properly tracks loop nesting depth, classifies recursion (linear / logarithmic / exponential / factorial / memoized), and produces structured indicators + reasoning. ([#41](https://github.com/open-devhub/quillbot/issues/41)) ## `v1.1.1` - 2026-07-13 ### Fixed -- Normalize `git://` repository URLs for Discord links in `npm` command +- Normalize `git://` repository URLs for Discord links in `npm` command ([#32](https://github.com/open-devhub/quillbot/issues/32)) - Optimize `run` command and expand its judge0 language alias map - Harden `http` command against SSRF and DoS - Fix reDoS risk from unrestricted user-controlled RegExp From c6a7a09fbca512ff73aed38c84da2df5e7f2ed56 Mon Sep 17 00:00:00 2001 From: Caleb Date: Mon, 20 Jul 2026 17:22:47 +0300 Subject: [PATCH 3/3] ci: add pull request template --- .github/pull_request_template.md | 38 ++++++++++++++++++++++++++++++++ package.json | 4 ++-- 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..a2884b8 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,38 @@ +## What does this PR do? + +Explain here what you changed and why. Doesn't need to be long, just enough +for a reviewer to understand the change without reading every line of +diff. If it was a bug fix, briefly describe what was broken. Delete this paragraph and replace it with your description. + +## Closes + +List any issues that are linked to this pull request (e.g., `Closes #123`). + +- Closes # + +## Type of change(s) + +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation update +- [ ] Style / UX change +- [ ] Refactor (no functional change) +- [ ] Performance improvement +- [ ] Other + +## Checklist + +- [ ] I've tested this change locally and it works as expected +- [ ] `bun run typecheck` or `npm run typecheck` completes without errors +- [ ] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (e.g. `fix: ...`, `feat: ...`, `chore: ...`) +- [ ] I've updated relevant docs (README, comments, etc.) if this change needs it +- [ ] No leftover `console.log` or debug code + +## Screenshots / recordings (if applicable) + +If this changes anything visual, a before/after screenshot or a short clip makes review a lot faster. Delete this section if it doesn't apply. + +## Anything else the reviewer should know? + +Extra context, known limitations, or specific things you'd like a +second opinion on. Delete this section if there's nothing to add. diff --git a/package.json b/package.json index 5f0cb7f..c2b6074 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,11 @@ "type": "module", "scripts": { "start": "bun run src/index.ts", - "dev": "bun run --watch src/index.ts", "format": "prettier --write .", "check:conflicts": "bun src/scripts/conflicts.ts", "lint": "eslint .", - "lint:fix": "eslint . --fix" + "lint:fix": "eslint . --fix", + "typecheck": "tsc --noEmit" }, "keywords": [], "author": "Caleb Ephrem",