|
| 1 | +// SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +// Julia the Viper - Code Analyzer for Python/JavaScript/Ruby |
| 3 | +// Analyzes legacy code for termination guarantees, purity, and complexity |
| 4 | + |
| 5 | +/** Supported analysis target languages */ |
| 6 | +type language = [#python | #javascript | #ruby] |
| 7 | + |
| 8 | +/** Severity levels for analysis warnings */ |
| 9 | +type severity = [#error | #warning | #info] |
| 10 | + |
| 11 | +/** Types of arithmetic data operations */ |
| 12 | +type dataOperationType = [#add | #subtract | #multiply | #divide | #other] |
| 13 | + |
| 14 | +/** Types of control flow nodes */ |
| 15 | +type controlFlowType = [#"if" | #"while" | #"for" | #function_call | #"return"] |
| 16 | + |
| 17 | +/** Types of suggestions the analyzer can produce */ |
| 18 | +type suggestionType = [#extract_to_jtv | #mark_pure | #bound_loop] |
| 19 | + |
| 20 | +/** Complexity metrics for a single function */ |
| 21 | +type complexityAnalysis = { |
| 22 | + cyclomaticComplexity: int, |
| 23 | + cognitiveComplexity: int, |
| 24 | + loops: int, |
| 25 | + conditionals: int, |
| 26 | + recursion: bool, |
| 27 | +} |
| 28 | + |
| 29 | +/** A single data operation found in the source */ |
| 30 | +type dataOperation = { |
| 31 | + @as("type") operationType: dataOperationType, |
| 32 | + line: int, |
| 33 | + safe: bool, |
| 34 | +} |
| 35 | + |
| 36 | +/** A single control flow node found in the source */ |
| 37 | +type controlFlowNode = { |
| 38 | + @as("type") nodeType: controlFlowType, |
| 39 | + line: int, |
| 40 | + terminating: bool, |
| 41 | +} |
| 42 | + |
| 43 | +/** Analysis result for a single function */ |
| 44 | +type functionAnalysis = { |
| 45 | + name: string, |
| 46 | + line: int, |
| 47 | + mutable isPure: bool, |
| 48 | + mutable terminates: bool, |
| 49 | + complexity: complexityAnalysis, |
| 50 | + dataOperations: array<dataOperation>, |
| 51 | + controlFlow: array<controlFlowNode>, |
| 52 | +} |
| 53 | + |
| 54 | +/** A warning produced during analysis */ |
| 55 | +type warning = { |
| 56 | + severity: severity, |
| 57 | + message: string, |
| 58 | + line: int, |
| 59 | + suggestion: option<string>, |
| 60 | +} |
| 61 | + |
| 62 | +/** A suggestion for improving the analyzed code */ |
| 63 | +type suggestion = { |
| 64 | + @as("type") suggestionType: suggestionType, |
| 65 | + message: string, |
| 66 | + line: int, |
| 67 | + codeSnippet: string, |
| 68 | + jtvEquivalent: option<string>, |
| 69 | +} |
| 70 | + |
| 71 | +/** Complete analysis result for a file */ |
| 72 | +type analysisResult = { |
| 73 | + file: string, |
| 74 | + language: language, |
| 75 | + mutable functions: array<functionAnalysis>, |
| 76 | + mutable warnings: array<warning>, |
| 77 | + mutable suggestions: array<suggestion>, |
| 78 | +} |
| 79 | + |
| 80 | +// -- FFI bindings for Deno APIs -- |
| 81 | + |
| 82 | +@module("Deno") @val |
| 83 | +external readTextFile: string => Js.Promise2.t<string> = "readTextFile" |
| 84 | + |
| 85 | +@module("Deno") @val |
| 86 | +external denoArgs: array<string> = "args" |
| 87 | + |
| 88 | +@module("Deno") @val |
| 89 | +external denoExit: int => unit = "exit" |
| 90 | + |
| 91 | +// -- Helper functions -- |
| 92 | + |
| 93 | +/** Check if a line is a function declaration for the given language */ |
| 94 | +let isFunctionDeclaration = (line: string, lang: language): bool => { |
| 95 | + switch lang { |
| 96 | + | #python => Js.String2.startsWith(line, "def ") |
| 97 | + | #javascript => |
| 98 | + Js.String2.includes(line, "function ") || Js.String2.includes(line, "=> ") |
| 99 | + | #ruby => Js.String2.startsWith(line, "def ") |
| 100 | + } |
| 101 | +} |
| 102 | + |
| 103 | +/** Extract the function name from a declaration line */ |
| 104 | +let extractFunctionName = (line: string): string => { |
| 105 | + let re = %re("/(?:def|function)\s+(\w+)/") |
| 106 | + switch Js.Re.exec_(re, line) { |
| 107 | + | Some(result) => |
| 108 | + switch Js.Nullable.toOption(Js.Re.captures(result)[1]) { |
| 109 | + | Some(name) => name |
| 110 | + | None => "anonymous" |
| 111 | + } |
| 112 | + | None => "anonymous" |
| 113 | + } |
| 114 | +} |
| 115 | + |
| 116 | +/** Convert a line to a JtV equivalent suggestion */ |
| 117 | +let convertToJtv = (line: string): string => { |
| 118 | + if Js.String2.includes(line, "*") { |
| 119 | + "// Use loop with addition in JtV: for i in 0..n { sum = sum + x }" |
| 120 | + } else if Js.String2.includes(line, "/") { |
| 121 | + "// Division not directly supported - use rational type or iterative subtraction" |
| 122 | + } else { |
| 123 | + line |
| 124 | + ->Js.String2.replaceByRe(%re("/\*/g"), "+") |
| 125 | + ->Js.String2.replaceByRe(%re("/\//g"), "+") |
| 126 | + } |
| 127 | +} |
| 128 | + |
| 129 | +/** Create a fresh complexity analysis with default values */ |
| 130 | +let makeComplexity = (): complexityAnalysis => { |
| 131 | + cyclomaticComplexity: 1, |
| 132 | + cognitiveComplexity: 0, |
| 133 | + loops: 0, |
| 134 | + conditionals: 0, |
| 135 | + recursion: false, |
| 136 | +} |
| 137 | + |
| 138 | +/** Analyze source code and produce an analysis result */ |
| 139 | +let analyzeCode = (code: string, filePath: string, lang: language): analysisResult => { |
| 140 | + let result: analysisResult = { |
| 141 | + file: filePath, |
| 142 | + language: lang, |
| 143 | + functions: [], |
| 144 | + warnings: [], |
| 145 | + suggestions: [], |
| 146 | + } |
| 147 | + |
| 148 | + let lines = Js.String2.split(code, "\n") |
| 149 | + let currentFunction = ref(None) |
| 150 | + let braceDepth = ref(0) |
| 151 | + |
| 152 | + lines->Belt.Array.forEachWithIndex((i, rawLine) => { |
| 153 | + let line = Js.String2.trim(rawLine) |
| 154 | + let lineNum = i + 1 |
| 155 | + |
| 156 | + // Detect function declarations |
| 157 | + if isFunctionDeclaration(line, lang) { |
| 158 | + switch currentFunction.contents { |
| 159 | + | Some(func) => |
| 160 | + result.functions = Belt.Array.concat(result.functions, [func]) |
| 161 | + | None => () |
| 162 | + } |
| 163 | + |
| 164 | + let funcName = extractFunctionName(line) |
| 165 | + currentFunction := |
| 166 | + Some({ |
| 167 | + name: funcName, |
| 168 | + line: lineNum, |
| 169 | + isPure: true, |
| 170 | + terminates: true, |
| 171 | + complexity: makeComplexity(), |
| 172 | + dataOperations: [], |
| 173 | + controlFlow: [], |
| 174 | + }) |
| 175 | + } |
| 176 | + |
| 177 | + switch currentFunction.contents { |
| 178 | + | None => () |
| 179 | + | Some(func) => { |
| 180 | + // Analyze data operations |
| 181 | + if Js.String2.includes(line, "+") && !Js.String2.includes(line, "++") { |
| 182 | + let op: dataOperation = {operationType: #add, line: lineNum, safe: true} |
| 183 | + ignore( |
| 184 | + Js.Array2.push(func.dataOperations, op), |
| 185 | + ) |
| 186 | + } else if Js.String2.includes(line, "*") || Js.String2.includes(line, "/") { |
| 187 | + let opType = if Js.String2.includes(line, "*") { |
| 188 | + #multiply |
| 189 | + } else { |
| 190 | + #divide |
| 191 | + } |
| 192 | + let op: dataOperation = {operationType: opType, line: lineNum, safe: false} |
| 193 | + ignore(Js.Array2.push(func.dataOperations, op)) |
| 194 | + |
| 195 | + let sug: suggestion = { |
| 196 | + suggestionType: #extract_to_jtv, |
| 197 | + message: "Consider extracting pure computation to JtV for guaranteed termination", |
| 198 | + line: lineNum, |
| 199 | + codeSnippet: line, |
| 200 | + jtvEquivalent: Some(convertToJtv(line)), |
| 201 | + } |
| 202 | + result.suggestions = Belt.Array.concat(result.suggestions, [sug]) |
| 203 | + } |
| 204 | + |
| 205 | + // Detect control flow - conditionals |
| 206 | + if Js.String2.includes(line, "if") || Js.String2.includes(line, "else") { |
| 207 | + let cfNode: controlFlowNode = {nodeType: #"if", line: lineNum, terminating: true} |
| 208 | + ignore(Js.Array2.push(func.controlFlow, cfNode)) |
| 209 | + } |
| 210 | + |
| 211 | + // Detect control flow - loops |
| 212 | + if Js.String2.includes(line, "while") || Js.String2.includes(line, "for") { |
| 213 | + func.terminates = false |
| 214 | + let loopType = if Js.String2.includes(line, "while") { |
| 215 | + #"while" |
| 216 | + } else { |
| 217 | + #"for" |
| 218 | + } |
| 219 | + let cfNode: controlFlowNode = {nodeType: loopType, line: lineNum, terminating: false} |
| 220 | + ignore(Js.Array2.push(func.controlFlow, cfNode)) |
| 221 | + |
| 222 | + let warn: warning = { |
| 223 | + severity: #warning, |
| 224 | + message: "Unbounded loop detected - termination not guaranteed", |
| 225 | + line: lineNum, |
| 226 | + suggestion: Some("Add loop bound or extract to JtV with range-based iteration"), |
| 227 | + } |
| 228 | + result.warnings = Belt.Array.concat(result.warnings, [warn]) |
| 229 | + } |
| 230 | + |
| 231 | + // Detect side effects |
| 232 | + if ( |
| 233 | + Js.String2.includes(line, "print") || |
| 234 | + Js.String2.includes(line, "console.log") || |
| 235 | + Js.String2.includes(line, "puts") || |
| 236 | + Js.String2.includes(line, "write") || |
| 237 | + Js.String2.includes(line, "fetch") || |
| 238 | + Js.String2.includes(line, "axios") |
| 239 | + ) { |
| 240 | + func.isPure = false |
| 241 | + |
| 242 | + let warn: warning = { |
| 243 | + severity: #info, |
| 244 | + message: "Function has side effects (I/O operations)", |
| 245 | + line: lineNum, |
| 246 | + suggestion: Some("Consider separating pure computation from side effects"), |
| 247 | + } |
| 248 | + result.warnings = Belt.Array.concat(result.warnings, [warn]) |
| 249 | + } |
| 250 | + |
| 251 | + // Detect recursion |
| 252 | + if Js.String2.includes(line, func.name ++ "(") { |
| 253 | + func.terminates = false |
| 254 | + |
| 255 | + let warn: warning = { |
| 256 | + severity: #warning, |
| 257 | + message: "Recursive function - termination not guaranteed", |
| 258 | + line: lineNum, |
| 259 | + suggestion: Some("Convert to iterative form or prove termination"), |
| 260 | + } |
| 261 | + result.warnings = Belt.Array.concat(result.warnings, [warn]) |
| 262 | + } |
| 263 | + |
| 264 | + // Track brace depth for function boundaries |
| 265 | + let openBraces = |
| 266 | + Js.String2.match_(line, %re("/{/g")) |
| 267 | + ->Belt.Option.mapWithDefault(0, Js.Array2.length) |
| 268 | + let closeBraces = |
| 269 | + Js.String2.match_(line, %re("/}/g")) |
| 270 | + ->Belt.Option.mapWithDefault(0, Js.Array2.length) |
| 271 | + braceDepth := braceDepth.contents + openBraces - closeBraces |
| 272 | + |
| 273 | + if braceDepth.contents == 0 { |
| 274 | + result.functions = Belt.Array.concat(result.functions, [func]) |
| 275 | + currentFunction := None |
| 276 | + } |
| 277 | + } |
| 278 | + } |
| 279 | + }) |
| 280 | + |
| 281 | + // Add final function if still in progress |
| 282 | + switch currentFunction.contents { |
| 283 | + | Some(func) => |
| 284 | + result.functions = Belt.Array.concat(result.functions, [func]) |
| 285 | + | None => () |
| 286 | + } |
| 287 | + |
| 288 | + // Generate overall suggestions |
| 289 | + let pureFunctions = result.functions->Belt.Array.keep(f => f.isPure && f.terminates) |
| 290 | + if Belt.Array.length(pureFunctions) > 0 { |
| 291 | + let names = pureFunctions->Belt.Array.map(f => f.name)->Js.Array2.joinWith(", ") |
| 292 | + let count = Belt.Array.length(pureFunctions)->Belt.Int.toString |
| 293 | + let sug: suggestion = { |
| 294 | + suggestionType: #extract_to_jtv, |
| 295 | + message: count ++ " pure, terminating functions found - excellent candidates for JtV extraction", |
| 296 | + line: 0, |
| 297 | + codeSnippet: names, |
| 298 | + jtvEquivalent: None, |
| 299 | + } |
| 300 | + result.suggestions = Belt.Array.concat(result.suggestions, [sug]) |
| 301 | + } |
| 302 | + |
| 303 | + let highComplexity = |
| 304 | + result.functions->Belt.Array.keep(f => f.complexity.cyclomaticComplexity > 10) |
| 305 | + if Belt.Array.length(highComplexity) > 0 { |
| 306 | + let count = Belt.Array.length(highComplexity)->Belt.Int.toString |
| 307 | + let warn: warning = { |
| 308 | + severity: #warning, |
| 309 | + message: count ++ " functions with high complexity - consider refactoring", |
| 310 | + line: 0, |
| 311 | + suggestion: None, |
| 312 | + } |
| 313 | + result.warnings = Belt.Array.concat(result.warnings, [warn]) |
| 314 | + } |
| 315 | + |
| 316 | + result |
| 317 | +} |
| 318 | + |
| 319 | +/** Print a human-readable analysis report to the console */ |
| 320 | +let printReport = (result: analysisResult): unit => { |
| 321 | + Js.log("\n=== JtV Analysis Report: " ++ result.file ++ " ===\n") |
| 322 | + Js.log("Language: " ++ (result.language :> string)) |
| 323 | + Js.log("Functions analyzed: " ++ Belt.Int.toString(Belt.Array.length(result.functions)) ++ "\n") |
| 324 | + |
| 325 | + let pure = result.functions->Belt.Array.keep(f => f.isPure)->Belt.Array.length |
| 326 | + let terminating = result.functions->Belt.Array.keep(f => f.terminates)->Belt.Array.length |
| 327 | + let total = Belt.Array.length(result.functions) |
| 328 | + |
| 329 | + Js.log( |
| 330 | + "Pure functions: " ++ Belt.Int.toString(pure) ++ "/" ++ Belt.Int.toString(total), |
| 331 | + ) |
| 332 | + Js.log( |
| 333 | + "Terminating functions: " ++ Belt.Int.toString(terminating) ++ "/" ++ Belt.Int.toString(total) ++ "\n", |
| 334 | + ) |
| 335 | + |
| 336 | + if Belt.Array.length(result.warnings) > 0 { |
| 337 | + Js.log("Warnings (" ++ Belt.Int.toString(Belt.Array.length(result.warnings)) ++ "):") |
| 338 | + result.warnings->Belt.Array.forEach(w => { |
| 339 | + let icon = switch w.severity { |
| 340 | + | #error => "[ERROR]" |
| 341 | + | #warning => "[WARN]" |
| 342 | + | #info => "[INFO]" |
| 343 | + } |
| 344 | + Js.log(" " ++ icon ++ " Line " ++ Belt.Int.toString(w.line) ++ ": " ++ w.message) |
| 345 | + switch w.suggestion { |
| 346 | + | Some(s) => Js.log(" -> " ++ s) |
| 347 | + | None => () |
| 348 | + } |
| 349 | + }) |
| 350 | + Js.log("") |
| 351 | + } |
| 352 | + |
| 353 | + if Belt.Array.length(result.suggestions) > 0 { |
| 354 | + Js.log("Suggestions (" ++ Belt.Int.toString(Belt.Array.length(result.suggestions)) ++ "):") |
| 355 | + result.suggestions->Belt.Array.forEach(s => { |
| 356 | + Js.log(" [TIP] Line " ++ Belt.Int.toString(s.line) ++ ": " ++ s.message) |
| 357 | + switch s.jtvEquivalent { |
| 358 | + | Some(equiv) => Js.log(" JtV: " ++ equiv) |
| 359 | + | None => () |
| 360 | + } |
| 361 | + }) |
| 362 | + Js.log("") |
| 363 | + } |
| 364 | + |
| 365 | + Js.log("Function Details:") |
| 366 | + result.functions->Belt.Array.forEach(func => { |
| 367 | + let pureIcon = if func.isPure { "Y" } else { "N" } |
| 368 | + let termIcon = if func.terminates { "Y" } else { "N" } |
| 369 | + Js.log("\n " ++ func.name ++ " (line " ++ Belt.Int.toString(func.line) ++ ")") |
| 370 | + Js.log(" Pure: " ++ pureIcon ++ " | Terminates: " ++ termIcon) |
| 371 | + Js.log( |
| 372 | + " Complexity: " ++ Belt.Int.toString(func.complexity.cyclomaticComplexity) ++ " (cyclomatic)", |
| 373 | + ) |
| 374 | + Js.log( |
| 375 | + " Loops: " ++ |
| 376 | + Belt.Int.toString(func.complexity.loops) ++ |
| 377 | + " | Conditionals: " ++ |
| 378 | + Belt.Int.toString(func.complexity.conditionals), |
| 379 | + ) |
| 380 | + }) |
| 381 | + |
| 382 | + Js.log("\n=== End Report ===\n") |
| 383 | +} |
| 384 | + |
| 385 | +/** Analyze a file by reading it from disk (async, requires Deno) */ |
| 386 | +let analyzeFile = (filePath: string, lang: language): Js.Promise2.t<analysisResult> => { |
| 387 | + readTextFile(filePath)->Js.Promise2.then(code => { |
| 388 | + Js.Promise2.resolve(analyzeCode(code, filePath, lang)) |
| 389 | + }) |
| 390 | +} |
0 commit comments