From aa81e791e1cabca13862107247e00c934f847e89 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:29:14 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(evangeliser):=20implement=20phases=201?= =?UTF-8?q?-5=20=E2=80=94=20scanner,=20tests,=20CLI,=2040=20patterns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReScript Evangeliser goes from 50% to ~90% completion: - Phase 1: Consolidate canonical/satellite, fix SPDX to PMPL-1.0-or-later, fix npxβ†’Deno, update rescript.json for v12 deprecations - Phase 2: Scanner.res + Analyser.res with regex pattern detection engine, confidence scoring, coverage analysis, and difficulty assessment - Phase 3: 38 tests across 6 suites (Types, Glyphs, Narrative, Patterns, Scanner, Analyser) β€” all passing - Phase 4: Expand from 15 to 40 patterns covering all 21 categories (Destructuring, Variants, PipeOperator, StateMachines, DataModeling, etc.) - Phase 5: CLI with scan/patterns/legend/stats commands, RAW/FOLDED/GLYPHED views, plain/markdown/html output formats Co-Authored-By: Claude Opus 4.6 --- .../packages/tooling/evangeliser/.gitignore | 7 +- .../tooling/evangeliser/bin/evangeliser.js | 6 + .../packages/tooling/evangeliser/deno.json | 19 +- .../packages/tooling/evangeliser/justfile | 4 +- .../tooling/evangeliser/rescript.json | 13 +- .../tooling/evangeliser/src/Analyser.res | 134 ++ .../tooling/evangeliser/src/Analyser.resi | 11 + .../packages/tooling/evangeliser/src/Cli.res | 158 ++ .../tooling/evangeliser/src/Glyphs.res | 2 +- .../tooling/evangeliser/src/Narrative.res | 2 +- .../tooling/evangeliser/src/Output.res | 205 +++ .../tooling/evangeliser/src/Patterns.res | 1325 +++++++++++++++++ .../tooling/evangeliser/src/Scanner.res | 110 ++ .../tooling/evangeliser/src/Scanner.resi | 14 + .../tooling/evangeliser/src/Types.res | 4 +- .../evangeliser/test/Analyser_test.res | 127 ++ .../tooling/evangeliser/test/Glyphs_test.res | 104 ++ .../evangeliser/test/Narrative_test.res | 110 ++ .../evangeliser/test/Patterns_test.res | 140 ++ .../tooling/evangeliser/test/Scanner_test.res | 155 ++ .../tooling/evangeliser/test/Types_test.res | 64 + .../tooling/evangeliser/test/run_all.js | 14 + 22 files changed, 2704 insertions(+), 24 deletions(-) create mode 100644 rescript-ecosystem/packages/tooling/evangeliser/bin/evangeliser.js create mode 100644 rescript-ecosystem/packages/tooling/evangeliser/src/Analyser.res create mode 100644 rescript-ecosystem/packages/tooling/evangeliser/src/Analyser.resi create mode 100644 rescript-ecosystem/packages/tooling/evangeliser/src/Cli.res create mode 100644 rescript-ecosystem/packages/tooling/evangeliser/src/Output.res create mode 100644 rescript-ecosystem/packages/tooling/evangeliser/src/Patterns.res create mode 100644 rescript-ecosystem/packages/tooling/evangeliser/src/Scanner.res create mode 100644 rescript-ecosystem/packages/tooling/evangeliser/src/Scanner.resi create mode 100644 rescript-ecosystem/packages/tooling/evangeliser/test/Analyser_test.res create mode 100644 rescript-ecosystem/packages/tooling/evangeliser/test/Glyphs_test.res create mode 100644 rescript-ecosystem/packages/tooling/evangeliser/test/Narrative_test.res create mode 100644 rescript-ecosystem/packages/tooling/evangeliser/test/Patterns_test.res create mode 100644 rescript-ecosystem/packages/tooling/evangeliser/test/Scanner_test.res create mode 100644 rescript-ecosystem/packages/tooling/evangeliser/test/Types_test.res create mode 100644 rescript-ecosystem/packages/tooling/evangeliser/test/run_all.js diff --git a/rescript-ecosystem/packages/tooling/evangeliser/.gitignore b/rescript-ecosystem/packages/tooling/evangeliser/.gitignore index 0396ed4d..e96204e0 100644 --- a/rescript-ecosystem/packages/tooling/evangeliser/.gitignore +++ b/rescript-ecosystem/packages/tooling/evangeliser/.gitignore @@ -1,4 +1,4 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-License-Identifier: PMPL-1.0-or-later # RSR-compliant .gitignore # OS & Editor @@ -44,6 +44,7 @@ erl_crash.dump *.res.js *.bs.js .merlin +!/test/run_all.js # Deno deno.lock @@ -57,7 +58,9 @@ __pycache__/ # Ada/SPARK *.ali /obj/ -/bin/ + +# CLI entry point (track this, even though it's JS) +!/bin/evangeliser.js # Haskell /.stack-work/ diff --git a/rescript-ecosystem/packages/tooling/evangeliser/bin/evangeliser.js b/rescript-ecosystem/packages/tooling/evangeliser/bin/evangeliser.js new file mode 100644 index 00000000..9ed14377 --- /dev/null +++ b/rescript-ecosystem/packages/tooling/evangeliser/bin/evangeliser.js @@ -0,0 +1,6 @@ +#!/usr/bin/env -S deno run --allow-read --allow-env +// SPDX-License-Identifier: PMPL-1.0-or-later +// Entry point for the rescript-evangeliser CLI +// Imports and runs the compiled ReScript CLI module + +import "../src/Cli.res.js"; diff --git a/rescript-ecosystem/packages/tooling/evangeliser/deno.json b/rescript-ecosystem/packages/tooling/evangeliser/deno.json index 3291cd86..2ad96696 100644 --- a/rescript-ecosystem/packages/tooling/evangeliser/deno.json +++ b/rescript-ecosystem/packages/tooling/evangeliser/deno.json @@ -2,27 +2,24 @@ "$schema": "https://deno.land/x/deno/cli/schemas/config-file.v1.json", "name": "@hyperpolymath/rescript-evangeliser", "version": "0.1.0", - "license": "MIT OR Palimpsest-0.8", + "license": "PMPL-1.0-or-later", "tasks": { - "build": "deno run -A scripts/build.ts", - "build:rescript": "deno run -A npm:rescript build", - "clean": "deno run -A scripts/clean.ts", + "build": "deno run -A npm:rescript build", + "build:watch": "deno run -A npm:rescript build -w", + "clean": "deno run -A npm:rescript clean", "lint": "deno lint", "fmt": "deno fmt", - "check": "deno check scripts/*.ts", "validate": "deno run -A scripts/validate.ts", - "test": "deno test --allow-read", + "test": "deno test --allow-read --allow-env test/", "pre-commit": "deno task lint && deno task fmt --check && deno task validate" }, "imports": { "@std/fs": "jsr:@std/fs@^1", "@std/path": "jsr:@std/path@^1", "@std/assert": "jsr:@std/assert@^1", - "rescript": "^12.0.0" - }, - "compilerOptions": { - "strict": true, - "noImplicitAny": true + "rescript": "npm:rescript@^12.0.0", + "@rescript/core": "npm:@rescript/core@^1.6.1", + "@rescript/runtime/": "npm:/@rescript/runtime@12.2.0/" }, "fmt": { "useTabs": false, diff --git a/rescript-ecosystem/packages/tooling/evangeliser/justfile b/rescript-ecosystem/packages/tooling/evangeliser/justfile index a07658b8..bd52f48e 100644 --- a/rescript-ecosystem/packages/tooling/evangeliser/justfile +++ b/rescript-ecosystem/packages/tooling/evangeliser/justfile @@ -1,6 +1,6 @@ # justfile for ReScript Evangeliser # https://github.com/casey/just -# SPDX-License-Identifier: MIT OR Palimpsest-0.8 +# SPDX-License-Identifier: PMPL-1.0-or-later # # Per Hyperpolymath policy: # - Use Deno, not npm/bun @@ -21,7 +21,7 @@ build: # Build in watch mode watch: @echo "πŸ‘€ Watching for changes..." - npx rescript build -w + deno run -A npm:rescript build -w # Clean build artifacts clean: diff --git a/rescript-ecosystem/packages/tooling/evangeliser/rescript.json b/rescript-ecosystem/packages/tooling/evangeliser/rescript.json index 89d6824d..c8b4b133 100644 --- a/rescript-ecosystem/packages/tooling/evangeliser/rescript.json +++ b/rescript-ecosystem/packages/tooling/evangeliser/rescript.json @@ -1,24 +1,27 @@ { "name": "rescript-evangeliser", - "version": "0.1.0", "sources": [ { "dir": "src", "subdirs": true + }, + { + "dir": "test", + "subdirs": true, + "type": "dev" } ], "package-specs": [ { - "module": "es6", + "module": "esmodule", "in-source": true } ], "suffix": ".res.js", - "bs-dependencies": [ + "dependencies": [ "@rescript/core" ], "warnings": { "number": "+a-4-9-27-40-42-48-50-61-102-109" - }, - "uncurried": true + } } diff --git a/rescript-ecosystem/packages/tooling/evangeliser/src/Analyser.res b/rescript-ecosystem/packages/tooling/evangeliser/src/Analyser.res new file mode 100644 index 00000000..18ebfc39 --- /dev/null +++ b/rescript-ecosystem/packages/tooling/evangeliser/src/Analyser.res @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Analyser: Orchestrates scanning and produces analysis results +// Computes coverage, difficulty assessment, and pattern suggestions + +open Types + +// Determine overall difficulty based on matched patterns +let assessDifficulty = (matches: array): difficultyLevel => { + let advancedCount = matches->Array.filter(m => m.pattern.difficulty === Advanced)->Array.length + let intermediateCount = + matches->Array.filter(m => m.pattern.difficulty === Intermediate)->Array.length + + if advancedCount > 0 { + Advanced + } else if intermediateCount > 0 { + Intermediate + } else { + Beginner + } +} + +// Suggest next patterns to learn based on what was matched +let suggestNextPatterns = ( + matches: array, + allPatterns: array, +): array => { + let matchedIds = matches->Array.map(m => m.pattern.id) + let matchedCategories = + matches + ->Array.map(m => categoryToString(m.pattern.category)) + ->Set.fromArray + + // Suggest related patterns from matched categories that weren't found + let relatedSuggestions = + matches + ->Array.flatMap(m => m.pattern.relatedPatterns) + ->Array.filter(id => !(matchedIds->Array.includes(id))) + ->Array.filterMap(id => allPatterns->Array.find(p => p.id === id)) + + // Suggest patterns from unmatched categories (prioritise Beginner) + let unmatchedCategorySuggestions = + allPatterns + ->Array.filter(p => !(matchedCategories->Set.has(categoryToString(p.category)))) + ->Array.filter(p => p.difficulty === Beginner) + + // Combine, deduplicate, take top 5 + let seen = Dict.make() + Array.concat(relatedSuggestions, unmatchedCategorySuggestions) + ->Array.filter(p => { + switch seen->Dict.get(p.id) { + | Some(_) => false + | None => + seen->Dict.set(p.id, true) + true + } + }) + ->Array.slice(~start=0, ~end=5) +} + +// Compute coverage: what percentage of available categories were matched +let computeCoverage = (matches: array): float => { + let totalCategories = 20 // from Types.patternCategory + let matchedCategories = + matches + ->Array.map(m => categoryToString(m.pattern.category)) + ->Set.fromArray + ->Set.size + + if totalCategories === 0 { + 0.0 + } else { + Int.toFloat(matchedCategories) /. Int.toFloat(totalCategories) *. 100.0 + } +} + +// Run full analysis on a code string +let analyse = (code: string): analysisResult => { + let startTime = Date.now() + let matches = Scanner.scanAll(code) + let endTime = Date.now() + + { + matches, + totalPatterns: Patterns.getPatternCount(), + coveragePercentage: computeCoverage(matches), + difficulty: assessDifficulty(matches), + suggestedNextPatterns: suggestNextPatterns(matches, Patterns.patternLibrary), + analysisTime: endTime -. startTime, + memoryUsed: 0, + } +} + +// Analyse with a custom pattern set +let analyseWithPatterns = (code: string, patterns: array): analysisResult => { + let startTime = Date.now() + let matches = Scanner.scanCode(code, patterns) + let endTime = Date.now() + + { + matches, + totalPatterns: patterns->Array.length, + coveragePercentage: computeCoverage(matches), + difficulty: assessDifficulty(matches), + suggestedNextPatterns: suggestNextPatterns(matches, patterns), + analysisTime: endTime -. startTime, + memoryUsed: 0, + } +} + +// Generate a narrative summary for the analysis +let summarise = (result: analysisResult): string => { + let matchCount = result.matches->Array.length + let uniquePatterns = Scanner.uniquePatterns(result.matches)->Array.length + let diffStr = difficultyToString(result.difficulty) + let coverageStr = Float.toFixed(result.coveragePercentage, ~digits=1) + + if matchCount === 0 { + "No JavaScript patterns detected. Try pasting some JavaScript code to see how ReScript can improve it!" + } else { + let patternsWord = if uniquePatterns === 1 { "pattern" } else { "patterns" } + `Found ${Int.toString(matchCount)} match(es) across ${Int.toString(uniquePatterns)} unique ${patternsWord}.\n` ++ + `Code complexity: ${diffStr}\n` ++ + `Category coverage: ${coverageStr}%\n` ++ + if result.suggestedNextPatterns->Array.length > 0 { + let suggestions = + result.suggestedNextPatterns + ->Array.map(p => p.name) + ->Array.join(", ") + `\nSuggested next patterns to explore: ${suggestions}` + } else { + "" + } + } +} diff --git a/rescript-ecosystem/packages/tooling/evangeliser/src/Analyser.resi b/rescript-ecosystem/packages/tooling/evangeliser/src/Analyser.resi new file mode 100644 index 00000000..4362449d --- /dev/null +++ b/rescript-ecosystem/packages/tooling/evangeliser/src/Analyser.resi @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Analyser: public interface for code analysis orchestration + +// Run full analysis on JavaScript code using the built-in pattern library +let analyse: (string) => Types.analysisResult + +// Run analysis with a custom set of patterns +let analyseWithPatterns: (string, array) => Types.analysisResult + +// Generate a human-readable narrative summary of analysis results +let summarise: (Types.analysisResult) => string diff --git a/rescript-ecosystem/packages/tooling/evangeliser/src/Cli.res b/rescript-ecosystem/packages/tooling/evangeliser/src/Cli.res new file mode 100644 index 00000000..dd668e5e --- /dev/null +++ b/rescript-ecosystem/packages/tooling/evangeliser/src/Cli.res @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// CLI: Entry point for the rescript-evangeliser command-line tool +// Usage: evangeliser scan | patterns | legend + +open Types + +// Read file contents via Deno or Node fs +@module("node:fs") external readFileSync: (string, string) => string = "readFileSync" + +// Read command-line arguments +@val external argv: array = "process.argv" + +// Parse CLI options from args +type cliOptions = { + command: string, + file: option, + format: string, + difficulty: option, + view: viewLayer, +} + +let parseArgs = (args: array): cliOptions => { + // Skip first two args (deno/node + script path) + let userArgs = args->Array.slice(~start=2, ~end=Array.length(args)) + + let command = userArgs->Array.get(0)->Option.getOr("help") + let file = ref(None) + let format = ref("plain") + let difficulty = ref(None) + let view = ref(RAW) + + userArgs->Array.forEachWithIndex((_arg, idx) => { + let arg = userArgs->Array.getUnsafe(idx) + switch arg { + | "--format" => + switch userArgs->Array.get(idx + 1) { + | Some("markdown") => format := "markdown" + | Some("html") => format := "html" + | _ => format := "plain" + } + | "--difficulty" => + switch userArgs->Array.get(idx + 1) { + | Some("beginner") => difficulty := Some(Beginner) + | Some("intermediate") => difficulty := Some(Intermediate) + | Some("advanced") => difficulty := Some(Advanced) + | _ => () + } + | "--view" => + switch userArgs->Array.get(idx + 1) { + | Some("raw") => view := RAW + | Some("folded") => view := FOLDED + | Some("glyphed") => view := GLYPHED + | _ => () + } + | _ => + // First non-flag arg after command is the file + if idx > 0 && !(String.startsWith(arg, "--")) { + let prevArg = userArgs->Array.get(idx - 1)->Option.getOr("") + if !(String.startsWith(prevArg, "--")) || idx === 1 { + if file.contents === None && idx >= 1 { + file := Some(arg) + } + } + } + } + }) + + { + command, + file: file.contents, + format: format.contents, + difficulty: difficulty.contents, + view: view.contents, + } +} + +let showHelp = (): string => { + `ReScript Evangeliser - "Celebrate good, minimize bad, show better" + +Usage: + evangeliser scan Scan a JavaScript file for improvable patterns + evangeliser patterns List all available transformation patterns + evangeliser legend Show the glyph legend + evangeliser stats Show pattern library statistics + evangeliser help Show this help message + +Options: + --format plain|markdown|html Output format (default: plain) + --difficulty beginner|intermediate|advanced Filter patterns by difficulty + --view raw|folded|glyphed View layer (default: raw) + +Examples: + evangeliser scan app.js + evangeliser scan utils.js --format markdown --view folded + evangeliser patterns --difficulty beginner +` +} + +let showStats = (): string => { + let stats = Patterns.getPatternStats() + let lines = ref(`Pattern Library Statistics\n`) + lines := lines.contents ++ `Total patterns: ${Int.toString(stats.total)}\n\n` + lines := lines.contents ++ `By difficulty:\n` + stats.byDifficulty->Dict.toArray->Array.forEach(((key, count)) => { + lines := lines.contents ++ ` ${key}: ${Int.toString(count)}\n` + }) + lines := lines.contents ++ `\nBy category:\n` + stats.byCategory->Dict.toArray->Array.forEach(((key, count)) => { + lines := lines.contents ++ ` ${key}: ${Int.toString(count)}\n` + }) + lines.contents +} + +let run = (): unit => { + let opts = parseArgs(argv) + + let output = switch opts.command { + | "scan" => + switch opts.file { + | None => "Error: Please specify a JavaScript file to scan.\n\nUsage: evangeliser scan \n" + | Some(filePath) => + try { + let code = readFileSync(filePath, "utf-8") + let result = switch opts.difficulty { + | None => Analyser.analyse(code) + | Some(diff) => + let filtered = Patterns.getPatternsByDifficulty(diff) + Analyser.analyseWithPatterns(code, filtered) + } + Output.format(result, opts.view, opts.format) + } catch { + | JsExn(e) => + let msg = e->JsExn.message->Option.getOr("Unknown error") + `Error reading file '${filePath}': ${msg}\n` + } + } + + | "patterns" => + Output.formatPatternList(opts.format) + + | "legend" => + Glyphs.createGlyphLegend() + + | "stats" => + showStats() + + | "help" | "--help" | "-h" => + showHelp() + + | unknown => + `Unknown command: '${unknown}'\n\n` ++ showHelp() + } + + Console.log(output) +} + +// Execute +run() diff --git a/rescript-ecosystem/packages/tooling/evangeliser/src/Glyphs.res b/rescript-ecosystem/packages/tooling/evangeliser/src/Glyphs.res index f5434b43..181c74db 100644 --- a/rescript-ecosystem/packages/tooling/evangeliser/src/Glyphs.res +++ b/rescript-ecosystem/packages/tooling/evangeliser/src/Glyphs.res @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT OR Palimpsest-0.8 +// SPDX-License-Identifier: PMPL-1.0-or-later // Makaton-inspired glyph system for ReScript Evangeliser // Glyphs transcend syntax to show semantic meaning. diff --git a/rescript-ecosystem/packages/tooling/evangeliser/src/Narrative.res b/rescript-ecosystem/packages/tooling/evangeliser/src/Narrative.res index dc46fe10..d5716515 100644 --- a/rescript-ecosystem/packages/tooling/evangeliser/src/Narrative.res +++ b/rescript-ecosystem/packages/tooling/evangeliser/src/Narrative.res @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT OR Palimpsest-0.8 +// SPDX-License-Identifier: PMPL-1.0-or-later // Narrative generation for ReScript Evangeliser // Philosophy: "Celebrate good, minimize bad, show better" // - NEVER shame developers diff --git a/rescript-ecosystem/packages/tooling/evangeliser/src/Output.res b/rescript-ecosystem/packages/tooling/evangeliser/src/Output.res new file mode 100644 index 00000000..012f19fd --- /dev/null +++ b/rescript-ecosystem/packages/tooling/evangeliser/src/Output.res @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Output: Format analysis results for terminal display +// Supports RAW, FOLDED, and GLYPHED view layers + +open Types + +// ANSI colour codes for terminal output +let bold = s => `\x1b[1m${s}\x1b[0m` +let green = s => `\x1b[32m${s}\x1b[0m` +let yellow = s => `\x1b[33m${s}\x1b[0m` +let cyan = s => `\x1b[36m${s}\x1b[0m` +let dim = s => `\x1b[2m${s}\x1b[0m` +let magenta = s => `\x1b[35m${s}\x1b[0m` + +// Format a single pattern match for RAW view +let formatMatchRaw = (m: patternMatch, format: string): string => { + let glyphBar = m.pattern.glyphs->Array.join(" ") + let diffStr = difficultyToString(m.pattern.difficulty) + let narrative = Narrative.formatNarrative(m.pattern.narrative, format) + + switch format { + | "markdown" => + `### ${glyphBar} ${m.pattern.name}\n\n` ++ + `**Difficulty:** ${diffStr} | **Confidence:** ${Float.toFixed(m.pattern.confidence *. 100.0, ~digits=0)}%\n\n` ++ + `**JavaScript:**\n\`\`\`javascript\n${m.pattern.jsExample}\n\`\`\`\n\n` ++ + `**ReScript:**\n\`\`\`rescript\n${m.pattern.rescriptExample}\n\`\`\`\n\n` ++ + narrative ++ "\n\n---\n" + + | "html" => + `
\n` ++ + `

${glyphBar} ${m.pattern.name}

\n` ++ + `

Difficulty: ${diffStr} | Confidence: ${Float.toFixed(m.pattern.confidence *. 100.0, ~digits=0)}%

\n` ++ + `
${m.pattern.jsExample}
\n` ++ + `
${m.pattern.rescriptExample}
\n` ++ + narrative ++ + `\n
\n` + + | _ => + // plain text (terminal) + bold(`${glyphBar} ${m.pattern.name}`) ++ "\n" ++ + dim(`Difficulty: ${diffStr} | Confidence: ${Float.toFixed(m.pattern.confidence *. 100.0, ~digits=0)}%`) ++ "\n\n" ++ + cyan("JavaScript:") ++ "\n" ++ m.pattern.jsExample ++ "\n\n" ++ + green("ReScript:") ++ "\n" ++ m.pattern.rescriptExample ++ "\n\n" ++ + narrative ++ "\n\n" ++ + dim("---") ++ "\n" + } +} + +// Format analysis results in RAW view (each match shown individually) +let formatRaw = (result: analysisResult, format: string): string => { + if result.matches->Array.length === 0 { + "No patterns detected. Try pasting some JavaScript code!\n" + } else { + let header = switch format { + | "markdown" => "# ReScript Evangeliser - Analysis Results\n\n" + | "html" => "

ReScript Evangeliser - Analysis Results

\n" + | _ => bold("ReScript Evangeliser - Analysis Results") ++ "\n\n" + } + + let summary = Analyser.summarise(result) + let matches = + result.matches + ->Array.map(m => formatMatchRaw(m, format)) + ->Array.join("\n") + + header ++ summary ++ "\n\n" ++ matches + } +} + +// Format analysis results in FOLDED view (grouped by category) +let formatFolded = (result: analysisResult, format: string): string => { + if result.matches->Array.length === 0 { + "No patterns detected. Try pasting some JavaScript code!\n" + } else { + let header = switch format { + | "markdown" => "# ReScript Evangeliser - Analysis Results (Grouped)\n\n" + | "html" => "

ReScript Evangeliser - Analysis Results (Grouped)

\n" + | _ => bold("ReScript Evangeliser - Analysis Results (Grouped)") ++ "\n\n" + } + + let summary = Analyser.summarise(result) + + // Group matches by category + let groups: Dict.t> = Dict.make() + result.matches->Array.forEach(m => { + let cat = categoryToString(m.pattern.category) + let existing = groups->Dict.get(cat)->Option.getOr([]) + groups->Dict.set(cat, existing->Array.concat([m])) + }) + + let body = + groups + ->Dict.toArray + ->Array.map(((cat, matches)) => { + let catHeader = switch format { + | "markdown" => `## ${cat} (${Int.toString(matches->Array.length)} matches)\n\n` + | "html" => `

${cat} (${Int.toString(matches->Array.length)} matches)

\n` + | _ => yellow(`[${cat}]`) ++ ` ${dim(`(${Int.toString(matches->Array.length)} matches)`)}` ++ "\n\n" + } + let matchStr = + matches + ->Array.map(m => formatMatchRaw(m, format)) + ->Array.join("\n") + catHeader ++ matchStr + }) + ->Array.join("\n") + + header ++ summary ++ "\n\n" ++ body + } +} + +// Format analysis results in GLYPHED view (glyph-annotated summary) +let formatGlyphed = (result: analysisResult, format: string): string => { + if result.matches->Array.length === 0 { + "No patterns detected. Try pasting some JavaScript code!\n" + } else { + let header = switch format { + | "markdown" => "# ReScript Evangeliser - Glyph Overview\n\n" + | "html" => "

ReScript Evangeliser - Glyph Overview

\n" + | _ => bold("ReScript Evangeliser - Glyph Overview") ++ "\n\n" + } + + let uniquePatterns = Scanner.uniquePatterns(result.matches) + + let glyphMap = + uniquePatterns + ->Array.map(p => { + let glyphs = p.glyphs->Array.join(" ") + switch format { + | "markdown" => `- ${glyphs} **${p.name}**: ${p.narrative.better}\n` + | "html" => `
  • ${glyphs} ${p.name}: ${p.narrative.better}
  • \n` + | _ => `${glyphs} ${magenta(p.name)}: ${p.narrative.better}\n` + } + }) + ->Array.join("") + + let legend = switch format { + | "plain" => "\n" ++ dim("Use 'evangeliser legend' to see the full glyph legend.") ++ "\n" + | _ => "" + } + + header ++ Analyser.summarise(result) ++ "\n\n" ++ glyphMap ++ legend + } +} + +// Format results using the specified view layer +let format = (result: analysisResult, view: viewLayer, outputFormat: string): string => { + switch view { + | RAW => formatRaw(result, outputFormat) + | FOLDED => formatFolded(result, outputFormat) + | GLYPHED => formatGlyphed(result, outputFormat) + | WYSIWYG => formatRaw(result, "html") // WYSIWYG falls back to HTML for now + } +} + +// Format the pattern list for the `patterns` command +let formatPatternList = (format: string): string => { + let stats = Patterns.getPatternStats() + let header = switch format { + | "markdown" => + `# Pattern Library (${Int.toString(stats.total)} patterns)\n\n` + | _ => + bold(`Pattern Library (${Int.toString(stats.total)} patterns)`) ++ "\n\n" + } + + let categories = [ + NullSafety, Async, ErrorHandling, ArrayOperations, Conditionals, + Destructuring, Defaults, Functional, Templates, ArrowFunctions, + Variants, Modules, TypeSafety, Immutability, PatternMatching, + PipeOperator, OopToFp, ClassesToRecords, InheritanceToComposition, + StateMachines, DataModeling, + ] + + let body = + categories + ->Array.map(cat => { + let patterns = Patterns.getPatternsByCategory(cat) + let catStr = categoryToString(cat) + let glyphs = Glyphs.getGlyphsForPattern(cat)->Array.join(" ") + let catHeader = switch format { + | "markdown" => + `## ${glyphs} ${catStr} (${Int.toString(patterns->Array.length)})\n\n` + | _ => + yellow(`${glyphs} ${catStr}`) ++ dim(` (${Int.toString(patterns->Array.length)})`) ++ "\n" + } + + let patternList = + patterns + ->Array.map(p => { + let diffStr = difficultyToString(p.difficulty) + switch format { + | "markdown" => + `- **${p.name}** (${diffStr}) - ${p.narrative.celebrate}\n` + | _ => + ` ${green(p.name)} ${dim(`[${diffStr}]`)} ${p.narrative.celebrate}\n` + } + }) + ->Array.join("") + + catHeader ++ patternList ++ "\n" + }) + ->Array.join("") + + header ++ body +} diff --git a/rescript-ecosystem/packages/tooling/evangeliser/src/Patterns.res b/rescript-ecosystem/packages/tooling/evangeliser/src/Patterns.res new file mode 100644 index 00000000..ba97c041 --- /dev/null +++ b/rescript-ecosystem/packages/tooling/evangeliser/src/Patterns.res @@ -0,0 +1,1325 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Pattern Library for ReScript Evangeliser +// 50+ transformation patterns from JavaScript/TypeScript to ReScript + +open Types + +// Helper to create pattern with glyphs +let makePattern = ( + ~id, + ~name, + ~category, + ~difficulty, + ~jsPattern, + ~confidence, + ~jsExample, + ~rescriptExample, + ~narrative, + ~tags, + ~relatedPatterns, + ~learningObjectives, + ~commonMistakes, + ~bestPractices, +): pattern => { + { + id, + name, + category, + difficulty, + jsPattern, + confidence, + jsExample, + rescriptExample, + narrative, + glyphs: Glyphs.getGlyphsForPattern(category), + tags, + relatedPatterns, + learningObjectives, + commonMistakes, + bestPractices, + } +} + +// NULL SAFETY PATTERNS +let nullCheckBasic = makePattern( + ~id="null-check-basic", + ~name="Basic Null Check", + ~category=NullSafety, + ~difficulty=Beginner, + ~jsPattern=`if\\s*\\(\\s*(\\w+)\\s*!==?\\s*null\\s*&&\\s*\\1\\s*!==?\\s*undefined\\s*\\)`, + ~confidence=0.9, + ~jsExample=`if (user !== null && user !== undefined) { + console.log(user.name); +}`, + ~rescriptExample=`switch user { +| Some(u) => Console.log(u.name) +| None => () +}`, + ~narrative={ + celebrate: "You're checking for null and undefined - that's great defensive programming!", + minimize: "The only small thing is that it's easy to forget one of these checks somewhere...", + better: "ReScript's Option type makes null safety automatic - you literally can't forget a check!", + safety: "The compiler won't let your code compile until you've handled both Some and None cases.", + example: "Option types eliminate an entire class of 'Cannot read property of undefined' errors!", + }, + ~tags=["null", "undefined", "option", "safety"], + ~relatedPatterns=["null-check-ternary", "optional-chaining"], + ~learningObjectives=["Understand Option<'a> type", "Pattern matching for null safety", "Exhaustiveness checking"], + ~commonMistakes=["Forgetting to handle None case", "Using null instead of None"], + ~bestPractices=[ + "Always use Option for nullable values", + "Use pattern matching to handle all cases", + "Prefer Option.map over explicit pattern matching", + ], +) + +let nullCheckTernary = makePattern( + ~id="null-check-ternary", + ~name="Null Check Ternary", + ~category=NullSafety, + ~difficulty=Beginner, + ~jsPattern=`(\\w+)\\s*\\?\\s*(\\w+)\\.\\w+\\s*:\\s*['\"]\\w+['\"]|null|undefined`, + ~confidence=0.85, + ~jsExample=`const name = user ? user.name : 'Guest';`, + ~rescriptExample=`let name = user->Option.mapOr("Guest", u => u.name)`, + ~narrative={ + celebrate: "Nice! You're using a ternary to handle the null case - that's concise!", + minimize: "It works great, though it can get verbose with nested checks...", + better: "ReScript's Option.mapOr does this in one line with type safety!", + safety: "The compiler ensures the default value matches the expected type.", + example: "Option.mapOr handles the None case automatically!", + }, + ~tags=["ternary", "default", "option"], + ~relatedPatterns=["null-check-basic", "default-params"], + ~learningObjectives=["Option.mapOr function", "Type-safe defaults"], + ~commonMistakes=["Wrong default type"], + ~bestPractices=["Use Option.mapOr for simple defaults"], +) + +let optionalChaining = makePattern( + ~id="optional-chaining", + ~name="Optional Chaining", + ~category=NullSafety, + ~difficulty=Intermediate, + ~jsPattern=`\\w+\\?\\.[\\w.?]+`, + ~confidence=0.95, + ~jsExample=`const city = user?.address?.city;`, + ~rescriptExample=`let city = user->Option.flatMap(u => u.address) + ->Option.map(a => a.city)`, + ~narrative={ + celebrate: "Excellent! You're using optional chaining - you know modern JavaScript!", + minimize: "It's really nice, though the result can still be undefined...", + better: "ReScript's Option.flatMap chains safely and the type tells you it's optional!", + safety: "The type system tracks the optionality through the entire chain.", + example: "Chain Option operations with flatMap and map!", + }, + ~tags=["optional-chaining", "nested", "option"], + ~relatedPatterns=["null-check-basic"], + ~learningObjectives=["Option.flatMap", "Chaining optional values"], + ~commonMistakes=["Using map instead of flatMap"], + ~bestPractices=["Use flatMap for nested Options"], +) + +// ASYNC PATTERNS +let asyncAwaitBasic = makePattern( + ~id="async-await-basic", + ~name="Async/Await", + ~category=Async, + ~difficulty=Intermediate, + ~jsPattern=`async\\s+function|\\basync\\s*\\(`, + ~confidence=0.95, + ~jsExample=`async function fetchUser(id) { + const response = await fetch(\`/api/users/\${id}\`); + return await response.json(); +}`, + ~rescriptExample=`let fetchUser = async (id) => { + let response = await fetch(\`/api/users/\${id}\`) + await response->Response.json +}`, + ~narrative={ + celebrate: "Excellent! You're using async/await - much better than callbacks!", + minimize: "It's great, though error handling can get scattered...", + better: "ReScript has async/await with type-safe Promises!", + safety: "Promise types are checked at compile time.", + example: "Type-safe async operations prevent common Promise mistakes!", + }, + ~tags=["async", "await", "promise"], + ~relatedPatterns=["promise-then", "try-catch-async"], + ~learningObjectives=["Async/await in ReScript", "Promise types"], + ~commonMistakes=["Forgetting await", "Missing error handling"], + ~bestPractices=["Always handle Promise rejections"], +) + +let promiseThen = makePattern( + ~id="promise-then", + ~name="Promise Then Chain", + ~category=Async, + ~difficulty=Beginner, + ~jsPattern=`\\.then\\s*\\([^)]+\\)`, + ~confidence=0.9, + ~jsExample=`fetch('/api/data') + .then(res => res.json()) + .then(data => console.log(data));`, + ~rescriptExample=`fetch("/api/data") +->Promise.then(res => res->Response.json) +->Promise.then(data => { + Console.log(data) + Promise.resolve() +})`, + ~narrative={ + celebrate: "Good use of Promise chains - you understand asynchronous flow!", + minimize: "It works well, though deeply nested thens can be hard to read...", + better: "ReScript's Promise.then with pipe operator makes chains more readable!", + safety: "Promise types are tracked through the chain.", + example: "Type-safe Promise chains with pipe operator!", + }, + ~tags=["promise", "then", "chain"], + ~relatedPatterns=["async-await-basic"], + ~learningObjectives=["Promise.then", "Promise chaining"], + ~commonMistakes=["Forgetting to return Promise"], + ~bestPractices=["Consider async/await for readability"], +) + +// ERROR HANDLING PATTERNS +let tryCatchBasic = makePattern( + ~id="try-catch-basic", + ~name="Basic Try/Catch", + ~category=ErrorHandling, + ~difficulty=Beginner, + ~jsPattern=`try\\s*\\{[^}]+\\}\\s*catch\\s*\\([^)]*\\)\\s*\\{`, + ~confidence=0.9, + ~jsExample=`try { + const result = riskyOperation(); + return result; +} catch (error) { + console.error(error); + return null; +}`, + ~rescriptExample=`switch riskyOperation() { +| result => Ok(result) +| exception Exn.Error(e) => Error(e) +}`, + ~narrative={ + celebrate: "Excellent error handling with try/catch - you're thinking defensively!", + minimize: "It works great, though errors aren't part of the function signature...", + better: "ReScript's Result type makes errors explicit in the type!", + safety: "Callers must handle both Ok and Error cases.", + example: "Result makes error handling impossible to ignore!", + }, + ~tags=["try-catch", "error", "result"], + ~relatedPatterns=["error-result-type"], + ~learningObjectives=["Result type", "Explicit error handling"], + ~commonMistakes=["Ignoring error cases"], + ~bestPractices=["Use Result for expected errors"], +) + +let errorResultType = makePattern( + ~id="error-result-type", + ~name="Result Type Pattern", + ~category=ErrorHandling, + ~difficulty=Intermediate, + ~jsPattern=`return\\s*\\{\\s*(?:success|ok|error)\\s*:`, + ~confidence=0.7, + ~jsExample=`function divide(a, b) { + if (b === 0) { + return { error: 'Division by zero' }; + } + return { success: a / b }; +}`, + ~rescriptExample=`let divide = (a, b) => { + if b == 0 { + Error("Division by zero") + } else { + Ok(a / b) + } +}`, + ~narrative={ + celebrate: "Smart! You're using an object to represent success/error - that's Result-like!", + minimize: "It works, though the shape isn't enforced by types...", + better: "ReScript's built-in Result type is exactly this, but type-safe!", + safety: "The compiler ensures you handle both Ok and Error.", + example: "Result<'a, 'error> is a first-class type!", + }, + ~tags=["result", "error", "success"], + ~relatedPatterns=["try-catch-basic"], + ~learningObjectives=["Result type usage", "Error as value"], + ~commonMistakes=["Inconsistent error shapes"], + ~bestPractices=["Always use Result for fallible operations"], +) + +// ARRAY OPERATION PATTERNS +let arrayMap = makePattern( + ~id="array-map", + ~name="Array.map", + ~category=ArrayOperations, + ~difficulty=Beginner, + ~jsPattern=`\\.map\\s*\\(\\s*(?:\\w+|\\([^)]*\\))\\s*=>`, + ~confidence=0.95, + ~jsExample=`const doubled = numbers.map(n => n * 2);`, + ~rescriptExample=`let doubled = numbers->Array.map(n => n * 2)`, + ~narrative={ + celebrate: "Perfect! Array.map is functional programming at its best!", + minimize: "Nothing wrong here, ReScript just adds the pipe operator...", + better: "ReScript's pipe operator makes data flow left-to-right!", + safety: "Type inference ensures the transformation is type-safe.", + example: "The -> operator makes transformations read like sentences!", + }, + ~tags=["array", "map", "transform"], + ~relatedPatterns=["array-filter", "array-reduce", "pipe-operator"], + ~learningObjectives=["Array.map", "Pipe operator"], + ~commonMistakes=["Side effects in map"], + ~bestPractices=["Keep map pure, use pipe for readability"], +) + +let arrayFilter = makePattern( + ~id="array-filter", + ~name="Array.filter", + ~category=ArrayOperations, + ~difficulty=Beginner, + ~jsPattern=`\\.filter\\s*\\(\\s*(?:\\w+|\\([^)]*\\))\\s*=>`, + ~confidence=0.95, + ~jsExample=`const evens = numbers.filter(n => n % 2 === 0);`, + ~rescriptExample=`let evens = numbers->Array.filter(n => mod(n, 2) == 0)`, + ~narrative={ + celebrate: "Great use of filter - functional programming done right!", + minimize: "Works perfectly, ReScript adds type-safe predicates...", + better: "ReScript's type system ensures your predicate always returns bool!", + safety: "The compiler checks that filter predicates return boolean.", + example: "Type-safe filtering with pipe operator!", + }, + ~tags=["array", "filter", "predicate"], + ~relatedPatterns=["array-map"], + ~learningObjectives=["Array.filter", "Boolean predicates"], + ~commonMistakes=["Predicate returning non-boolean"], + ~bestPractices=["Keep predicates pure and simple"], +) + +let arrayReduce = makePattern( + ~id="array-reduce", + ~name="Array.reduce", + ~category=ArrayOperations, + ~difficulty=Intermediate, + ~jsPattern=`\\.reduce\\s*\\(\\s*\\([^)]*\\)\\s*=>`, + ~confidence=0.9, + ~jsExample=`const sum = numbers.reduce((acc, n) => acc + n, 0);`, + ~rescriptExample=`let sum = numbers->Array.reduce(0, (acc, n) => acc + n)`, + ~narrative={ + celebrate: "Excellent! Array.reduce is a powerful functional pattern!", + minimize: "It's great, though the parameter order can be confusing...", + better: "ReScript puts the initial value first - more intuitive!", + safety: "Type inference ensures accumulator and result types match.", + example: "Type-safe reduce with clear parameter order!", + }, + ~tags=["array", "reduce", "fold"], + ~relatedPatterns=["array-map"], + ~learningObjectives=["Array.reduce", "Accumulator patterns"], + ~commonMistakes=["Wrong initial value type"], + ~bestPractices=["Consider specialized functions like sum, join"], +) + +let arrayFind = makePattern( + ~id="array-find", + ~name="Array.find", + ~category=ArrayOperations, + ~difficulty=Beginner, + ~jsPattern=`\\.find\\s*\\(\\s*(?:\\w+|\\([^)]*\\))\\s*=>`, + ~confidence=0.9, + ~jsExample=`const user = users.find(u => u.id === userId);`, + ~rescriptExample=`let user = users->Array.find(u => u.id == userId)`, + ~narrative={ + celebrate: "Good! You're using find to search arrays efficiently!", + minimize: "It works, though the result can be undefined...", + better: "ReScript's Array.find returns Option to handle 'not found' safely!", + safety: "The Option return type forces you to handle the not-found case.", + example: "Option<'a> makes missing values explicit!", + }, + ~tags=["array", "find", "search"], + ~relatedPatterns=["null-check-basic"], + ~learningObjectives=["Array.find with Option"], + ~commonMistakes=["Not checking for undefined"], + ~bestPractices=["Pattern match on find result"], +) + +// CONDITIONAL PATTERNS +let switchStatement = makePattern( + ~id="switch-statement", + ~name="Switch Statement", + ~category=Conditionals, + ~difficulty=Beginner, + ~jsPattern=`switch\\s*\\([^)]+\\)\\s*\\{`, + ~confidence=0.9, + ~jsExample=`switch (status) { + case 'loading': return 'Loading...'; + case 'success': return data; + case 'error': return 'Error!'; + default: return null; +}`, + ~rescriptExample=`switch status { +| Loading => "Loading..." +| Success(data) => data +| Error(_) => "Error!" +}`, + ~narrative={ + celebrate: "Good use of switch! You're handling multiple cases systematically.", + minimize: "It works, though the 'default' case can hide missing cases...", + better: "ReScript's pattern matching ensures you handle ALL cases - the compiler checks!", + safety: "The compiler ensures every variant is handled.", + example: "Exhaustive pattern matching catches missing cases at compile time!", + }, + ~tags=["switch", "conditionals", "pattern-matching"], + ~relatedPatterns=["if-else-chain"], + ~learningObjectives=["Pattern matching basics", "Exhaustiveness"], + ~commonMistakes=["Relying on default for unhandled cases"], + ~bestPractices=["Let the compiler ensure completeness"], +) + +let ifElseChain = makePattern( + ~id="if-else-chain", + ~name="If/Else Chain", + ~category=Conditionals, + ~difficulty=Beginner, + ~jsPattern=`if\\s*\\([^)]+\\)\\s*\\{[^}]*\\}\\s*else\\s*if`, + ~confidence=0.85, + ~jsExample=`if (x > 10) { + return 'large'; +} else if (x > 5) { + return 'medium'; +} else { + return 'small'; +}`, + ~rescriptExample=`if x > 10 { + "large" +} else if x > 5 { + "medium" +} else { + "small" +}`, + ~narrative={ + celebrate: "You're handling multiple conditions - that's thorough logic!", + minimize: "It works, though ReScript can make this even cleaner...", + better: "ReScript if/else is an expression that returns a value!", + safety: "All branches must return the same type.", + example: "Expression-based control flow is safer and cleaner!", + }, + ~tags=["if", "else", "conditionals"], + ~relatedPatterns=["switch-statement"], + ~learningObjectives=["If as expression", "Type consistency across branches"], + ~commonMistakes=["Inconsistent return types"], + ~bestPractices=["Consider pattern matching for complex conditions"], +) + +// FUNCTIONAL PATTERNS +let pureFunction = makePattern( + ~id="pure-function", + ~name="Pure Function", + ~category=Functional, + ~difficulty=Beginner, + ~jsPattern=`const\\s+\\w+\\s*=\\s*\\([^)]*\\)\\s*=>\\s*[^{]`, + ~confidence=0.6, + ~jsExample=`const add = (a, b) => a + b;`, + ~rescriptExample=`let add = (a, b) => a + b`, + ~narrative={ + celebrate: "Excellent! Pure functions are predictable and testable!", + minimize: "JavaScript doesn't enforce purity, but you're doing it right...", + better: "ReScript's immutability by default makes purity natural!", + safety: "No hidden state mutations possible.", + example: "Pure functions are the foundation of reliable code!", + }, + ~tags=["pure", "function", "immutable"], + ~relatedPatterns=["array-map"], + ~learningObjectives=["Pure functions", "Immutability"], + ~commonMistakes=["Accidental mutations"], + ~bestPractices=["Keep functions pure when possible"], +) + +let higherOrderFunction = makePattern( + ~id="higher-order-function", + ~name="Higher Order Function", + ~category=Functional, + ~difficulty=Intermediate, + ~jsPattern=`\\([^)]*=>\\s*\\([^)]*\\)\\s*=>`, + ~confidence=0.8, + ~jsExample=`const multiply = factor => num => num * factor; +const double = multiply(2);`, + ~rescriptExample=`let multiply = factor => num => num * factor +let double = multiply(2)`, + ~narrative={ + celebrate: "Great! Higher-order functions show advanced functional skills!", + minimize: "Works perfectly, ReScript's syntax is nearly identical...", + better: "ReScript's currying makes this pattern natural!", + safety: "Type inference tracks through all the layers.", + example: "Curried functions compose beautifully!", + }, + ~tags=["higher-order", "currying", "functional"], + ~relatedPatterns=["pure-function"], + ~learningObjectives=["Currying", "Function composition"], + ~commonMistakes=["Losing track of closure variables"], + ~bestPractices=["Use currying for reusable transformations"], +) + +// DESTRUCTURING PATTERNS +let objectDestructuring = makePattern( + ~id="object-destructuring", + ~name="Object Destructuring", + ~category=Destructuring, + ~difficulty=Beginner, + ~jsPattern=`const\\s*\\{[^}]+\\}\\s*=`, + ~confidence=0.9, + ~jsExample=`const { name, age, email } = user;`, + ~rescriptExample=`let {name, age, email} = user`, + ~narrative={ + celebrate: "Nice destructuring! You're extracting exactly what you need.", + minimize: "It's clean, though missing properties silently become undefined...", + better: "ReScript destructuring is type-checked - missing fields are compile errors!", + safety: "The compiler guarantees every destructured field exists on the record.", + example: "Type-safe destructuring means no 'undefined' surprises!", + }, + ~tags=["destructuring", "object", "record"], + ~relatedPatterns=["array-destructuring"], + ~learningObjectives=["Record destructuring", "Type-safe field access"], + ~commonMistakes=["Destructuring non-existent fields"], + ~bestPractices=["Destructure only what you need"], +) + +let arrayDestructuring = makePattern( + ~id="array-destructuring", + ~name="Array Destructuring", + ~category=Destructuring, + ~difficulty=Beginner, + ~jsPattern=`const\\s*\\[[^\\]]+\\]\\s*=`, + ~confidence=0.85, + ~jsExample=`const [first, second, ...rest] = items;`, + ~rescriptExample=`switch items { +| [first, second, ...rest] => // use values +| _ => // handle other cases +}`, + ~narrative={ + celebrate: "Great! Array destructuring makes your code expressive!", + minimize: "It works, though arrays might not have enough elements...", + better: "ReScript uses pattern matching for array destructuring with safety!", + safety: "Pattern matching forces you to handle the case where the array is too short.", + example: "Safe array destructuring through exhaustive pattern matching!", + }, + ~tags=["destructuring", "array", "pattern-matching"], + ~relatedPatterns=["object-destructuring"], + ~learningObjectives=["Array pattern matching", "Rest patterns"], + ~commonMistakes=["Assuming array has enough elements"], + ~bestPractices=["Always handle the empty/short case"], +) + +// DEFAULT PARAMETER PATTERNS +let defaultParams = makePattern( + ~id="default-params", + ~name="Default Parameters", + ~category=Defaults, + ~difficulty=Beginner, + ~jsPattern=`function\\s+\\w+\\s*\\([^)]*=\\s*[^,)]+`, + ~confidence=0.85, + ~jsExample=`function greet(name = 'World', greeting = 'Hello') { + return \`\${greeting}, \${name}!\`; +}`, + ~rescriptExample=`let greet = (~name="World", ~greeting="Hello") => { + \`\${greeting}, \${name}!\` +}`, + ~narrative={ + celebrate: "Smart use of default parameters - you're making your API flexible!", + minimize: "Works well, though optional and default can get confusing...", + better: "ReScript's labeled arguments with defaults are type-safe and self-documenting!", + safety: "The compiler ensures default values match the parameter type.", + example: "Labeled arguments make function calls self-documenting!", + }, + ~tags=["defaults", "parameters", "labeled"], + ~relatedPatterns=["null-check-ternary"], + ~learningObjectives=["Labeled arguments", "Default values"], + ~commonMistakes=["Wrong default type"], + ~bestPractices=["Use labeled arguments for clarity"], +) + +let nullishCoalescing = makePattern( + ~id="nullish-coalescing", + ~name="Nullish Coalescing", + ~category=Defaults, + ~difficulty=Beginner, + ~jsPattern=`\\w+\\s*\\?\\?\\s*`, + ~confidence=0.9, + ~jsExample=`const displayName = user.name ?? 'Anonymous';`, + ~rescriptExample=`let displayName = user.name->Option.getOr("Anonymous")`, + ~narrative={ + celebrate: "Good use of nullish coalescing - you know modern JavaScript!", + minimize: "It's concise, though ?? only covers null and undefined...", + better: "ReScript's Option.getOr is explicit about what 'missing' means!", + safety: "Option type makes the absence of a value part of the type signature.", + example: "Option.getOr is the type-safe equivalent of ??!", + }, + ~tags=["nullish", "coalescing", "default", "option"], + ~relatedPatterns=["default-params", "null-check-ternary"], + ~learningObjectives=["Option.getOr", "Default value patterns"], + ~commonMistakes=["Confusing ?? with ||"], + ~bestPractices=["Use Option.getOr for explicit defaults"], +) + +// TEMPLATE LITERAL PATTERNS +let templateLiterals = makePattern( + ~id="template-literals", + ~name="Template Literals", + ~category=Templates, + ~difficulty=Beginner, + ~jsPattern="`.+\\$\\{.+\\}.+`", + ~confidence=0.9, + ~jsExample="`Hello, ${name}! You have ${count} messages.`", + ~rescriptExample="`Hello, ${name}! You have ${Int.toString(count)} messages.`", + ~narrative={ + celebrate: "Nice! Template literals are much better than string concatenation!", + minimize: "They're great, though any expression can be interpolated without type checking...", + better: "ReScript template literals require explicit type conversion - no accidental coercion!", + safety: "The compiler ensures interpolated values are strings - no [object Object] surprises.", + example: "Type-safe string interpolation prevents silent coercion bugs!", + }, + ~tags=["template", "string", "interpolation"], + ~relatedPatterns=[], + ~learningObjectives=["String interpolation", "Explicit type conversion"], + ~commonMistakes=["Interpolating non-string values without conversion"], + ~bestPractices=["Always convert non-string values explicitly"], +) + +let taggedTemplates = makePattern( + ~id="tagged-templates", + ~name="Tagged Template Literals", + ~category=Templates, + ~difficulty=Advanced, + ~jsPattern=`\\w+\s*\`.+\\$\\{`, + ~confidence=0.7, + ~jsExample=`const query = sql\`SELECT * FROM users WHERE id = \${userId}\`;`, + ~rescriptExample=`// Use a dedicated query builder with type-safe parameters +let query = Sql.select(~table="users", ~where=Sql.eq("id", userId))`, + ~narrative={ + celebrate: "Advanced! Tagged templates show deep JavaScript knowledge!", + minimize: "They're powerful, though the tag function's types aren't checked...", + better: "ReScript uses dedicated type-safe builders for domain-specific strings!", + safety: "Type-safe query builders prevent SQL injection by construction.", + example: "Domain-specific builders are safer than string interpolation!", + }, + ~tags=["template", "tagged", "dsl"], + ~relatedPatterns=["template-literals"], + ~learningObjectives=["Domain-specific builders", "Type-safe DSLs"], + ~commonMistakes=["SQL injection through templates"], + ~bestPractices=["Use type-safe builders for SQL, HTML, etc."], +) + +// ARROW FUNCTION PATTERNS +let arrowFunction = makePattern( + ~id="arrow-function", + ~name="Arrow Function", + ~category=ArrowFunctions, + ~difficulty=Beginner, + ~jsPattern=`(?:const|let)\\s+\\w+\\s*=\\s*\\([^)]*\\)\\s*=>\\s*\\{`, + ~confidence=0.8, + ~jsExample=`const calculateTotal = (items) => { + return items.reduce((sum, item) => sum + item.price, 0); +};`, + ~rescriptExample=`let calculateTotal = items => { + items->Array.reduce(0, (sum, item) => sum + item.price) +}`, + ~narrative={ + celebrate: "Great! Arrow functions are concise and avoid 'this' confusion!", + minimize: "They're perfect, ReScript's syntax is just a tiny bit cleaner...", + better: "ReScript functions are always arrow-style with automatic return!", + safety: "No 'this' binding issues - all functions are lexically scoped.", + example: "ReScript functions always return the last expression - no 'return' needed!", + }, + ~tags=["arrow", "function", "lambda"], + ~relatedPatterns=["pure-function"], + ~learningObjectives=["Function syntax", "Implicit return"], + ~commonMistakes=["Forgetting return in multi-line arrows"], + ~bestPractices=["Use concise body when possible"], +) + +let arrowImplicitReturn = makePattern( + ~id="arrow-implicit-return", + ~name="Arrow Implicit Return", + ~category=ArrowFunctions, + ~difficulty=Beginner, + ~jsPattern=`(?:const|let)\\s+\\w+\\s*=\\s*\\([^)]*\\)\\s*=>\\s*[^{]`, + ~confidence=0.75, + ~jsExample=`const double = (n) => n * 2;`, + ~rescriptExample=`let double = n => n * 2`, + ~narrative={ + celebrate: "Clean! Implicit return makes your arrow functions concise!", + minimize: "It's already great, ReScript just drops the parentheses too...", + better: "ReScript's function syntax is minimal - everything is an expression!", + safety: "The return type is inferred and checked automatically.", + example: "Minimal syntax, maximum type safety!", + }, + ~tags=["arrow", "implicit-return", "concise"], + ~relatedPatterns=["arrow-function", "pure-function"], + ~learningObjectives=["Expression-based functions", "Type inference"], + ~commonMistakes=["Accidentally returning an object literal"], + ~bestPractices=["Prefer concise form for simple transforms"], +) + +// VARIANT PATTERNS +let enumToVariant = makePattern( + ~id="enum-to-variant", + ~name="Enum to Variant", + ~category=Variants, + ~difficulty=Intermediate, + ~jsPattern=`(?:const|var|let)\\s+\\w+\\s*=\\s*(?:Object\\.freeze\\()?\\{[^}]*:\\s*['\"]\\w+['\"]`, + ~confidence=0.7, + ~jsExample=`const Status = Object.freeze({ + LOADING: 'loading', + SUCCESS: 'success', + ERROR: 'error' +});`, + ~rescriptExample=`type status = Loading | Success | Error`, + ~narrative={ + celebrate: "Smart! You're creating an enum-like pattern for safety!", + minimize: "Object.freeze helps, but the values are still just strings at runtime...", + better: "ReScript variants are real types - no string matching, no typos possible!", + safety: "Variants are exhaustively checked - add a new case and the compiler shows every spot to update.", + example: "Variants make impossible states truly impossible!", + }, + ~tags=["enum", "variant", "type-safe"], + ~relatedPatterns=["switch-statement", "union-to-variant"], + ~learningObjectives=["Variant types", "Exhaustive matching"], + ~commonMistakes=["Typos in string enums"], + ~bestPractices=["Use variants for finite sets of values"], +) + +let unionToVariant = makePattern( + ~id="union-to-variant", + ~name="Union Type to Variant", + ~category=Variants, + ~difficulty=Intermediate, + ~jsPattern=`type\\s+\\w+\\s*=\\s*['\"]\\w+['\"]\\s*\\|`, + ~confidence=0.65, + ~jsExample=`// TypeScript: type Shape = 'circle' | 'square' | 'triangle'; +const area = (shape) => { + if (shape === 'circle') return Math.PI * r * r; + if (shape === 'square') return s * s; +};`, + ~rescriptExample=`type shape = Circle(float) | Square(float) | Triangle(float, float) + +let area = shape => + switch shape { + | Circle(r) => Math.Constants.pi *. r *. r + | Square(s) => s *. s + | Triangle(b, h) => 0.5 *. b *. h + }`, + ~narrative={ + celebrate: "Good thinking with union types - you want type safety!", + minimize: "String unions help, but they can't carry data...", + better: "ReScript variants carry data AND are exhaustively checked!", + safety: "Each variant case can hold different typed data - impossible to mix up.", + example: "Variants with payloads are like tagged unions on steroids!", + }, + ~tags=["union", "variant", "tagged-union"], + ~relatedPatterns=["enum-to-variant"], + ~learningObjectives=["Variants with payloads", "Tagged unions"], + ~commonMistakes=["Forgetting to handle new variant cases"], + ~bestPractices=["Give variants meaningful payload types"], +) + +// MODULE PATTERNS +let namespaceToModule = makePattern( + ~id="namespace-to-module", + ~name="Namespace to Module", + ~category=Modules, + ~difficulty=Intermediate, + ~jsPattern=`(?:export\\s+)?(?:const|class)\\s+\\w+\\s*=\\s*\\{`, + ~confidence=0.5, + ~jsExample=`export const MathUtils = { + add: (a, b) => a + b, + subtract: (a, b) => a - b, + multiply: (a, b) => a * b, +};`, + ~rescriptExample=`// MathUtils.res - each file IS a module +let add = (a, b) => a + b +let subtract = (a, b) => a - b +let multiply = (a, b) => a * b`, + ~narrative={ + celebrate: "Good! You're organizing code into namespaces - clean architecture!", + minimize: "Object namespaces work, but they're just convention, not enforced...", + better: "In ReScript, every file is automatically a module with proper encapsulation!", + safety: "Modules have interfaces (.resi files) that enforce what's public.", + example: "File = module, no boilerplate needed!", + }, + ~tags=["module", "namespace", "organization"], + ~relatedPatterns=["class-to-module"], + ~learningObjectives=["File-based modules", "Module interfaces"], + ~commonMistakes=["Over-nesting modules"], + ~bestPractices=["Use .resi files for public API control"], +) + +// TYPE SAFETY PATTERNS +let typeAssertionToType = makePattern( + ~id="type-assertion-to-type", + ~name="Type Assertion to Sound Type", + ~category=TypeSafety, + ~difficulty=Intermediate, + ~jsPattern=`as\\s+\\w+|<\\w+>\\w+`, + ~confidence=0.6, + ~jsExample=`const input = document.getElementById('name') as HTMLInputElement; +const value = (input as any).value;`, + ~rescriptExample=`// Use proper bindings instead of type assertions +@val @return(nullable) +external getElementById: string => option = "document.getElementById" + +switch getElementById("name") { +| Some(el) => // safely use element +| None => // handle missing element +}`, + ~narrative={ + celebrate: "You know about type annotations - that's type-aware programming!", + minimize: "Type assertions override the compiler - they can hide real errors...", + better: "ReScript has no type assertions - the type system is 100% sound!", + safety: "Sound types mean if it compiles, the types are correct. No 'as any' escape hatch.", + example: "100% sound type system - no type assertions, no type lies!", + }, + ~tags=["type-safety", "assertion", "sound"], + ~relatedPatterns=["any-to-typed"], + ~learningObjectives=["Sound type system", "External bindings"], + ~commonMistakes=["Reaching for 'as any'"], + ~bestPractices=["Write proper bindings instead of type assertions"], +) + +let anyToTyped = makePattern( + ~id="any-to-typed", + ~name="Any Type to Proper Type", + ~category=TypeSafety, + ~difficulty=Intermediate, + ~jsPattern=`:\\s*any\\b|as\\s+any`, + ~confidence=0.85, + ~jsExample=`function processData(data: any) { + return data.map((item: any) => item.name); +}`, + ~rescriptExample=`type item = {name: string} + +let processData = (data: array) => { + data->Array.map(item => item.name) +}`, + ~narrative={ + celebrate: "You're using TypeScript's type system - that's progress from plain JS!", + minimize: "'any' is sometimes needed, but it turns off type checking entirely...", + better: "ReScript has no 'any' type - everything is properly typed from the start!", + safety: "No escape hatches means the compiler catches ALL type errors.", + example: "No 'any', no 'unknown', no type holes - just correct types!", + }, + ~tags=["any", "type-safety", "typed"], + ~relatedPatterns=["type-assertion-to-type"], + ~learningObjectives=["Eliminating any", "Full type coverage"], + ~commonMistakes=["Using any as a quick fix"], + ~bestPractices=["Define proper types for all data"], +) + +// IMMUTABILITY PATTERNS +let constToLet = makePattern( + ~id="const-to-let", + ~name="Const to Immutable Let", + ~category=Immutability, + ~difficulty=Beginner, + ~jsPattern=`(?:let|var)\\s+\\w+\\s*=`, + ~confidence=0.5, + ~jsExample=`let count = 0; +count = count + 1; // mutation allowed! + +var name = 'initial'; +name = 'changed'; // mutation allowed!`, + ~rescriptExample=`let count = 0 +// count = count + 1 // ERROR: can't reassign let binding! + +let count2 = count + 1 // create a new binding instead`, + ~narrative={ + celebrate: "You're using variables to track state - the foundation of programming!", + minimize: "let and var allow reassignment, which can cause subtle bugs...", + better: "ReScript's let is immutable by default - you create new bindings instead!", + safety: "Immutable bindings prevent accidental state corruption.", + example: "Immutable by default, mutable only when you explicitly ask for it!", + }, + ~tags=["immutable", "let", "const", "binding"], + ~relatedPatterns=["spread-to-update"], + ~learningObjectives=["Immutable bindings", "Creating new values"], + ~commonMistakes=["Trying to reassign let bindings"], + ~bestPractices=["Prefer creating new bindings over mutation"], +) + +let spreadToUpdate = makePattern( + ~id="spread-to-update", + ~name="Spread to Record Update", + ~category=Immutability, + ~difficulty=Beginner, + ~jsPattern=`\\{\\s*\\.\\.\\.\\w+`, + ~confidence=0.9, + ~jsExample=`const updated = { ...user, name: 'New Name', age: 30 };`, + ~rescriptExample=`let updated = {...user, name: "New Name", age: 30}`, + ~narrative={ + celebrate: "Excellent! Spread operator for immutable updates - that's functional thinking!", + minimize: "It's great, though spread is shallow and types aren't checked...", + better: "ReScript's record update syntax is type-safe and looks almost identical!", + safety: "The compiler ensures updated fields exist and have the right types.", + example: "Type-safe record updates with familiar spread-like syntax!", + }, + ~tags=["spread", "immutable", "record-update"], + ~relatedPatterns=["const-to-let"], + ~learningObjectives=["Record update syntax", "Immutable data updates"], + ~commonMistakes=["Shallow copy issues"], + ~bestPractices=["Use record update for immutable modifications"], +) + +// PATTERN MATCHING PATTERNS +let switchToMatch = makePattern( + ~id="switch-to-match", + ~name="Switch to Pattern Match", + ~category=PatternMatching, + ~difficulty=Intermediate, + ~jsPattern=`switch\\s*\\([^)]+\\)\\s*\\{\\s*case`, + ~confidence=0.9, + ~jsExample=`switch (action.type) { + case 'INCREMENT': return { count: state.count + 1 }; + case 'DECREMENT': return { count: state.count - 1 }; + case 'RESET': return { count: 0 }; + default: return state; +}`, + ~rescriptExample=`switch action { +| Increment => {...state, count: state.count + 1} +| Decrement => {...state, count: state.count - 1} +| Reset => {...state, count: 0} +}`, + ~narrative={ + celebrate: "Good pattern! Switch on action type is a solid state management approach!", + minimize: "It works, but string matching can have typos and the default hides missing cases...", + better: "ReScript pattern matching on variants is exhaustive - no default needed!", + safety: "Add a new action and the compiler shows every switch that needs updating.", + example: "Exhaustive pattern matching is the heart of ReScript!", + }, + ~tags=["switch", "pattern-matching", "exhaustive"], + ~relatedPatterns=["switch-statement", "enum-to-variant"], + ~learningObjectives=["Deep pattern matching", "Exhaustiveness"], + ~commonMistakes=["Using default as a catch-all"], + ~bestPractices=["Never use a default/wildcard without good reason"], +) + +let nestedTernaryToMatch = makePattern( + ~id="nested-ternary-to-match", + ~name="Nested Ternary to Match", + ~category=PatternMatching, + ~difficulty=Intermediate, + ~jsPattern=`\\?[^:]+:[^?]+\\?`, + ~confidence=0.7, + ~jsExample=`const label = status === 'loading' ? 'Loading...' + : status === 'error' ? 'Error!' + : status === 'success' ? 'Done!' + : 'Unknown';`, + ~rescriptExample=`let label = switch status { +| Loading => "Loading..." +| Error(_) => "Error!" +| Success => "Done!" +}`, + ~narrative={ + celebrate: "You're handling multiple cases - that's thorough!", + minimize: "Nested ternaries work but can be hard to read...", + better: "ReScript's switch is cleaner and exhaustive - no 'Unknown' default needed!", + safety: "The compiler ensures you handle every possible case.", + example: "Pattern matching replaces nested ternaries with clarity!", + }, + ~tags=["ternary", "pattern-matching", "readability"], + ~relatedPatterns=["switch-to-match"], + ~learningObjectives=["Replacing ternaries", "Switch expressions"], + ~commonMistakes=["Deeply nested ternaries"], + ~bestPractices=["Use switch for more than 2 cases"], +) + +// PIPE OPERATOR PATTERNS +let chainToPipe = makePattern( + ~id="chain-to-pipe", + ~name="Method Chain to Pipe", + ~category=PipeOperator, + ~difficulty=Beginner, + ~jsPattern=`\\w+\\.\\w+\\([^)]*\\)\\.\\w+\\([^)]*\\)\\.\\w+`, + ~confidence=0.8, + ~jsExample=`const result = users + .filter(u => u.active) + .map(u => u.name) + .sort();`, + ~rescriptExample=`let result = users +->Array.filter(u => u.active) +->Array.map(u => u.name) +->Array.toSorted(String.compare)`, + ~narrative={ + celebrate: "Excellent method chaining! You know how to compose operations!", + minimize: "It's clean, though methods must be on the prototype...", + better: "ReScript's pipe operator works with ANY function, not just methods!", + safety: "Pipe operator is more flexible than method chaining and fully typed.", + example: "The -> operator: data flows left-to-right through any function!", + }, + ~tags=["pipe", "chain", "composition"], + ~relatedPatterns=["array-map", "array-filter"], + ~learningObjectives=["Pipe operator ->", "Data-first functions"], + ~commonMistakes=["Forgetting -> syntax"], + ~bestPractices=["Use pipe for readable data transformations"], +) + +let nestedCallsToPipe = makePattern( + ~id="nested-calls-to-pipe", + ~name="Nested Calls to Pipe", + ~category=PipeOperator, + ~difficulty=Beginner, + ~jsPattern=`\\w+\\(\\w+\\([^)]*\\)\\)`, + ~confidence=0.7, + ~jsExample=`const result = capitalize(trim(toLowerCase(input)));`, + ~rescriptExample=`let result = input +->String.toLowerCase +->String.trim +->String.capitalize`, + ~narrative={ + celebrate: "You're composing functions - that's functional programming!", + minimize: "Nested calls read inside-out, which can be confusing...", + better: "ReScript's pipe operator makes the flow read naturally left-to-right!", + safety: "Each step's types are checked as data flows through the pipe.", + example: "Read data transformations like a sentence, not an onion!", + }, + ~tags=["pipe", "nested", "readability"], + ~relatedPatterns=["chain-to-pipe"], + ~learningObjectives=["Pipe vs nesting", "Readable composition"], + ~commonMistakes=["Reading order confusion"], + ~bestPractices=["Use pipe for 2+ nested function calls"], +) + +// OOP TO FP PATTERNS +let classMethodToFunction = makePattern( + ~id="class-method-to-function", + ~name="Class Method to Function", + ~category=OopToFp, + ~difficulty=Intermediate, + ~jsPattern=`class\\s+\\w+\\s*\\{[^}]*\\w+\\s*\\([^)]*\\)\\s*\\{`, + ~confidence=0.7, + ~jsExample=`class Calculator { + add(a, b) { return a + b; } + subtract(a, b) { return a - b; } +} +const calc = new Calculator(); +calc.add(1, 2);`, + ~rescriptExample=`// Calculator.res +let add = (a, b) => a + b +let subtract = (a, b) => a - b + +// Usage: +Calculator.add(1, 2)`, + ~narrative={ + celebrate: "Good OOP! Classes organize related operations together!", + minimize: "Classes work, but they carry mutable state and 'this' binding issues...", + better: "ReScript modules group functions without 'this', 'new', or hidden state!", + safety: "Pure functions in modules - no constructor issues, no 'this' confusion.", + example: "Modules: all the organization of classes, none of the complexity!", + }, + ~tags=["class", "module", "oop", "fp"], + ~relatedPatterns=["namespace-to-module"], + ~learningObjectives=["Classes to modules", "Stateless programming"], + ~commonMistakes=["Trying to use 'this' in ReScript"], + ~bestPractices=["Think modules and records, not classes and instances"], +) + +// CLASSES TO RECORDS PATTERNS +let classToRecord = makePattern( + ~id="class-to-record", + ~name="Class to Record", + ~category=ClassesToRecords, + ~difficulty=Intermediate, + ~jsPattern=`class\\s+\\w+\\s*\\{\\s*constructor`, + ~confidence=0.8, + ~jsExample=`class User { + constructor(name, email, age) { + this.name = name; + this.email = email; + this.age = age; + } +}`, + ~rescriptExample=`type user = { + name: string, + email: string, + age: int, +} + +let makeUser = (~name, ~email, ~age) => {name, email, age}`, + ~narrative={ + celebrate: "Classes for data structures - you're organizing your data well!", + minimize: "Constructor classes are verbose and allow mutation of fields...", + better: "ReScript records are simpler, immutable, and fully typed!", + safety: "Records are immutable by default - no accidental field mutations.", + example: "Records: data without the ceremony of classes!", + }, + ~tags=["class", "record", "data"], + ~relatedPatterns=["class-method-to-function"], + ~learningObjectives=["Record types", "Constructor functions"], + ~commonMistakes=["Adding methods to records"], + ~bestPractices=["Use records for data, modules for functions"], +) + +// INHERITANCE TO COMPOSITION PATTERNS +let inheritanceToComposition = makePattern( + ~id="inheritance-to-composition", + ~name="Inheritance to Composition", + ~category=InheritanceToComposition, + ~difficulty=Advanced, + ~jsPattern=`class\\s+\\w+\\s+extends\\s+\\w+`, + ~confidence=0.9, + ~jsExample=`class Animal { speak() { return '...'; } } +class Dog extends Animal { speak() { return 'Woof!'; } } +class Cat extends Animal { speak() { return 'Meow!'; } }`, + ~rescriptExample=`type animal = Dog | Cat | Bird + +let speak = animal => + switch animal { + | Dog => "Woof!" + | Cat => "Meow!" + | Bird => "Tweet!" + }`, + ~narrative={ + celebrate: "You understand inheritance - it's a fundamental OOP concept!", + minimize: "Deep inheritance hierarchies can become rigid and hard to change...", + better: "ReScript variants + pattern matching give you polymorphism without inheritance!", + safety: "Adding a new variant forces you to update all pattern matches - no forgotten overrides.", + example: "Variants: polymorphism that the compiler checks exhaustively!", + }, + ~tags=["inheritance", "composition", "variant", "polymorphism"], + ~relatedPatterns=["class-to-record", "enum-to-variant"], + ~learningObjectives=["Composition over inheritance", "Variant-based polymorphism"], + ~commonMistakes=["Deep inheritance hierarchies"], + ~bestPractices=["Prefer variants and modules over class hierarchies"], +) + +// STATE MACHINE PATTERNS +let stateMachine = makePattern( + ~id="state-machine", + ~name="State Machine", + ~category=StateMachines, + ~difficulty=Advanced, + ~jsPattern=`(?:state|status)\\s*===?\\s*['\"]\\w+['\"]`, + ~confidence=0.6, + ~jsExample=`let state = 'idle'; +function transition(event) { + if (state === 'idle' && event === 'start') state = 'running'; + else if (state === 'running' && event === 'pause') state = 'paused'; + else if (state === 'paused' && event === 'resume') state = 'running'; + else if (state === 'running' && event === 'stop') state = 'idle'; +}`, + ~rescriptExample=`type state = Idle | Running | Paused +type event = Start | Pause | Resume | Stop + +let transition = (state, event) => + switch (state, event) { + | (Idle, Start) => Running + | (Running, Pause) => Paused + | (Paused, Resume) => Running + | (Running, Stop) => Idle + | (state, _) => state + }`, + ~narrative={ + celebrate: "State machines! You understand that systems have discrete states!", + minimize: "String-based states can have typos and invalid transitions...", + better: "ReScript variants make each state and transition explicit and type-checked!", + safety: "Invalid state transitions are caught at compile time, not runtime.", + example: "Type-safe state machines where illegal states are impossible!", + }, + ~tags=["state-machine", "variant", "transition"], + ~relatedPatterns=["enum-to-variant", "switch-to-match"], + ~learningObjectives=["Variant-based state machines", "Tuple pattern matching"], + ~commonMistakes=["Missing state transitions"], + ~bestPractices=["Model all states as variants, all events as variants"], +) + +// DATA MODELING PATTERNS +let interfaceToType = makePattern( + ~id="interface-to-type", + ~name="Interface to Type", + ~category=DataModeling, + ~difficulty=Intermediate, + ~jsPattern=`(?:interface|type)\\s+\\w+\\s*\\{`, + ~confidence=0.6, + ~jsExample=`interface User { + id: number; + name: string; + email: string; + role: 'admin' | 'user' | 'guest'; +}`, + ~rescriptExample=`type role = Admin | User | Guest + +type user = { + id: int, + name: string, + email: string, + role: role, +}`, + ~narrative={ + celebrate: "Great data modeling! Interfaces define clear contracts!", + minimize: "Interfaces are structural and can be widened accidentally...", + better: "ReScript types are nominal and variants replace string unions!", + safety: "Nominal types prevent accidental structural compatibility.", + example: "Types + variants = precise, exhaustive data modeling!", + }, + ~tags=["interface", "type", "record", "data-modeling"], + ~relatedPatterns=["class-to-record", "enum-to-variant"], + ~learningObjectives=["Record types", "Nominal typing"], + ~commonMistakes=["Expecting structural compatibility"], + ~bestPractices=["Model domain data with records and variants"], +) + +let nestedObjectToVariant = makePattern( + ~id="nested-object-to-variant", + ~name="Nested Object to Variant", + ~category=DataModeling, + ~difficulty=Advanced, + ~jsPattern=`\\{\\s*type\\s*:\\s*['\"]\\w+['\"]`, + ~confidence=0.7, + ~jsExample=`const response = { + type: 'success', + data: { users: [...] } +}; +// OR +const response = { + type: 'error', + message: 'Not found' +};`, + ~rescriptExample=`type response = + | Success({users: array}) + | Error({message: string}) + +switch response { +| Success({users}) => // use users +| Error({message}) => // handle error +}`, + ~narrative={ + celebrate: "Smart! Discriminated unions with a 'type' field - you're thinking in types!", + minimize: "The type field is just a string - easy to misspell or forget to check...", + better: "ReScript variants with inline records are this exact pattern, but type-safe!", + safety: "Each variant arm has its own typed payload - no casting needed.", + example: "Variants with inline records: the best of both worlds!", + }, + ~tags=["discriminated-union", "variant", "data-modeling"], + ~relatedPatterns=["union-to-variant", "interface-to-type"], + ~learningObjectives=["Inline records in variants", "Discriminated unions"], + ~commonMistakes=["Forgetting to destructure variant payload"], + ~bestPractices=["Use inline records for variant data"], +) + +// PATTERN LIBRARY +let patternLibrary: array = [ + // Null Safety (3) + nullCheckBasic, + nullCheckTernary, + optionalChaining, + // Async (2) + asyncAwaitBasic, + promiseThen, + // Error Handling (2) + tryCatchBasic, + errorResultType, + // Array Operations (4) + arrayMap, + arrayFilter, + arrayReduce, + arrayFind, + // Conditionals (2) + switchStatement, + ifElseChain, + // Functional (2) + pureFunction, + higherOrderFunction, + // Destructuring (2) + objectDestructuring, + arrayDestructuring, + // Defaults (2) + defaultParams, + nullishCoalescing, + // Templates (2) + templateLiterals, + taggedTemplates, + // Arrow Functions (2) + arrowFunction, + arrowImplicitReturn, + // Variants (2) + enumToVariant, + unionToVariant, + // Modules (1) + namespaceToModule, + // Type Safety (2) + typeAssertionToType, + anyToTyped, + // Immutability (2) + constToLet, + spreadToUpdate, + // Pattern Matching (2) + switchToMatch, + nestedTernaryToMatch, + // Pipe Operator (2) + chainToPipe, + nestedCallsToPipe, + // OOP to FP (1) + classMethodToFunction, + // Classes to Records (1) + classToRecord, + // Inheritance to Composition (1) + inheritanceToComposition, + // State Machines (1) + stateMachine, + // Data Modeling (2) + interfaceToType, + nestedObjectToVariant, +] + +// Get pattern by ID +let getPatternById = (id: string): option => { + patternLibrary->Array.find(p => p.id === id) +} + +// Get patterns by category +let getPatternsByCategory = (category: patternCategory): array => { + patternLibrary->Array.filter(p => p.category === category) +} + +// Get patterns by difficulty +let getPatternsByDifficulty = (difficulty: difficultyLevel): array => { + patternLibrary->Array.filter(p => p.difficulty === difficulty) +} + +// Create regex from pattern string +@val external createRegex: (string, string) => Nullable.t = "RegExp" + +// Detect patterns in code +let detectPatterns = (code: string): array => { + patternLibrary + ->Array.filter(pattern => { + switch createRegex(pattern.jsPattern, "")->Nullable.toOption { + | Some(regex) => regex->RegExp.test(code) + | None => false + } + }) + ->Array.toSorted((a, b) => b.confidence -. a.confidence) +} + +// Get total pattern count +let getPatternCount = (): int => { + patternLibrary->Array.length +} + +// Get pattern statistics +let getPatternStats = (): patternStats => { + let byCategory = Dict.make() + let byDifficulty = Dict.make() + + patternLibrary->Array.forEach(pattern => { + let catStr = categoryToString(pattern.category) + let diffStr = difficultyToString(pattern.difficulty) + + let catCount = byCategory->Dict.get(catStr)->Option.getOr(0) + byCategory->Dict.set(catStr, catCount + 1) + + let diffCount = byDifficulty->Dict.get(diffStr)->Option.getOr(0) + byDifficulty->Dict.set(diffStr, diffCount + 1) + }) + + { + total: patternLibrary->Array.length, + byCategory, + byDifficulty, + } +} diff --git a/rescript-ecosystem/packages/tooling/evangeliser/src/Scanner.res b/rescript-ecosystem/packages/tooling/evangeliser/src/Scanner.res new file mode 100644 index 00000000..62315f9c --- /dev/null +++ b/rescript-ecosystem/packages/tooling/evangeliser/src/Scanner.res @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Scanner: Matches JavaScript code against pattern library using regex detection +// Returns detailed patternMatch results with line numbers and confidence scores + +open Types + +// Create regex from pattern string (reuse from Patterns) +@val external createRegex: (string, string) => Nullable.t = "RegExp" + +// Split code into lines for line-number tracking +let splitLines = (code: string): array => { + code->String.split("\n") +} + +// Find the line number where a match occurs in the source code +let findMatchLine = (code: string, matchedText: string): int => { + let lines = splitLines(code) + let foundLine = ref(1) + lines->Array.forEachWithIndex((line, idx) => { + if String.includes(line, String.slice(matchedText, ~start=0, ~end=Math.Int.min(40, String.length(matchedText)))) { + foundLine := idx + 1 + } + }) + foundLine.contents +} + +// Count the number of lines in a matched code fragment +let countMatchLines = (matchedText: string): int => { + splitLines(matchedText)->Array.length +} + +// Scan a block of code against a single pattern, returning matches +let scanPattern = (code: string, pattern: pattern): array => { + switch createRegex(pattern.jsPattern, "gm")->Nullable.toOption { + | None => [] + | Some(regex) => + let matches = ref([]) + let safetyCounter = ref(0) + let continue_ = ref(true) + + while continue_.contents && safetyCounter.contents < 1000 { + safetyCounter := safetyCounter.contents + 1 + switch regex->RegExp.exec(code) { + | None => continue_ := false + | Some(result) => + let matchedText = result->RegExp.Result.input + let startLine = findMatchLine(code, matchedText) + let endLine = startLine + countMatchLines(matchedText) - 1 + + matches := + matches.contents->Array.concat([ + { + pattern, + code: matchedText, + startLine, + endLine, + confidence: pattern.confidence, + transformation: Some(pattern.rescriptExample), + }, + ]) + } + } + + matches.contents + } +} + +// Scan code against all patterns in the library +let scanCode = (code: string, patterns: array): array => { + patterns + ->Array.flatMap(pattern => scanPattern(code, pattern)) + ->Array.toSorted((a, b) => { + // Sort by confidence descending, then by line number ascending + let confDiff = b.confidence -. a.confidence + if confDiff != 0.0 { + confDiff + } else { + Int.toFloat(a.startLine - b.startLine) + } + }) +} + +// Scan code against the full pattern library +let scanAll = (code: string): array => { + scanCode(code, Patterns.patternLibrary) +} + +// Get unique patterns matched (deduplicated by pattern ID) +let uniquePatterns = (matches: array): array => { + let seen = Dict.make() + matches->Array.filter(m => { + switch seen->Dict.get(m.pattern.id) { + | Some(_) => false + | None => + seen->Dict.set(m.pattern.id, true) + true + } + })->Array.map(m => m.pattern) +} + +// Count matches per category +let matchesByCategory = (matches: array): Dict.t => { + let counts = Dict.make() + matches->Array.forEach(m => { + let cat = categoryToString(m.pattern.category) + let current = counts->Dict.get(cat)->Option.getOr(0) + counts->Dict.set(cat, current + 1) + }) + counts +} diff --git a/rescript-ecosystem/packages/tooling/evangeliser/src/Scanner.resi b/rescript-ecosystem/packages/tooling/evangeliser/src/Scanner.resi new file mode 100644 index 00000000..92c13f73 --- /dev/null +++ b/rescript-ecosystem/packages/tooling/evangeliser/src/Scanner.resi @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Scanner: public interface for JavaScript code pattern scanning + +// Scan a block of code against a specific set of patterns +let scanCode: (string, array) => array + +// Scan code against the full built-in pattern library +let scanAll: (string) => array + +// Deduplicate matches by pattern ID, returning unique patterns +let uniquePatterns: (array) => array + +// Count matches grouped by pattern category +let matchesByCategory: (array) => Dict.t diff --git a/rescript-ecosystem/packages/tooling/evangeliser/src/Types.res b/rescript-ecosystem/packages/tooling/evangeliser/src/Types.res index 83475852..f03f9f4e 100644 --- a/rescript-ecosystem/packages/tooling/evangeliser/src/Types.res +++ b/rescript-ecosystem/packages/tooling/evangeliser/src/Types.res @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT OR Palimpsest-0.8 +// SPDX-License-Identifier: PMPL-1.0-or-later // Core type definitions for ReScript Evangeliser // Philosophy: "Celebrate good, minimize bad, show better" @@ -96,7 +96,7 @@ type analysisResult = { } // User's learning progress -type learningProgress = { +type rec learningProgress = { patternsCompleted: Set.t, currentDifficulty: difficultyLevel, totalTransformations: int, diff --git a/rescript-ecosystem/packages/tooling/evangeliser/test/Analyser_test.res b/rescript-ecosystem/packages/tooling/evangeliser/test/Analyser_test.res new file mode 100644 index 00000000..46bcc19e --- /dev/null +++ b/rescript-ecosystem/packages/tooling/evangeliser/test/Analyser_test.res @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Tests for Analyser module - end-to-end analysis pipeline + +open Types + +let assert_ = (condition, message) => { + if !condition { + JsError.throwWithMessage(`FAIL: ${message}`) + } +} + +let testAnalyseEmptyCode = () => { + let result = Analyser.analyse("") + assert_(result.matches->Array.length === 0, "Empty code produces no matches") + assert_(result.coveragePercentage === 0.0, "Empty code has 0% coverage") + assert_(result.difficulty === Beginner, "Empty code defaults to Beginner difficulty") + Console.log("PASS: analyse('') returns empty result with Beginner difficulty") +} + +let testAnalyseSimpleJS = () => { + let code = `const doubled = numbers.map(n => n * 2); +const evens = numbers.filter(n => n % 2 === 0);` + let result = Analyser.analyse(code) + assert_(result.matches->Array.length > 0, "Simple JS code produces matches") + assert_(result.totalPatterns > 0, "totalPatterns is populated") + assert_(result.analysisTime >= 0.0, "analysisTime is non-negative") + Console.log("PASS: analyse with simple JS produces matches") +} + +let testAnalyseComplexJS = () => { + let code = ` +async function fetchUsers() { + try { + const response = await fetch('/api/users'); + const users = await response.json(); + return users.map(u => u.name).filter(n => n !== null); + } catch (error) { + console.error(error); + return []; + } +} + +if (user !== null && user !== undefined) { + const { name, email } = user; + const displayName = name ?? 'Anonymous'; +} +` + let result = Analyser.analyse(code) + assert_(result.matches->Array.length >= 3, "Complex JS matches multiple patterns") + assert_(result.coveragePercentage > 0.0, "Complex JS has non-zero coverage") + Console.log(`PASS: Complex JS analysis found ${Int.toString(result.matches->Array.length)} matches, ${Float.toFixed(result.coveragePercentage, ~digits=1)}% coverage`) +} + +let testDifficultyAssessment = () => { + // Beginner-only code + let beginnerCode = `const doubled = numbers.map(n => n * 2);` + let beginnerResult = Analyser.analyse(beginnerCode) + assert_(beginnerResult.difficulty === Beginner, "Simple map code is Beginner") + + // Code with advanced patterns + let advancedCode = `class Dog extends Animal { speak() { return 'Woof!'; } }` + let advancedResult = Analyser.analyse(advancedCode) + assert_(advancedResult.difficulty === Advanced, "Inheritance code is Advanced") + + Console.log("PASS: Difficulty assessment - Beginner and Advanced correctly identified") +} + +let testSuggestedNextPatterns = () => { + let code = `const doubled = numbers.map(n => n * 2);` + let result = Analyser.analyse(code) + assert_(result.suggestedNextPatterns->Array.length > 0, "Suggestions provided") + assert_(result.suggestedNextPatterns->Array.length <= 5, "At most 5 suggestions") + Console.log("PASS: suggestedNextPatterns returns 1-5 suggestions") +} + +let testAnalyseWithPatterns = () => { + let code = `const doubled = numbers.map(n => n * 2);` + let onlyArrayPatterns = Patterns.getPatternsByCategory(ArrayOperations) + let result = Analyser.analyseWithPatterns(code, onlyArrayPatterns) + assert_(result.totalPatterns === onlyArrayPatterns->Array.length, "totalPatterns matches custom set") + Console.log("PASS: analyseWithPatterns uses custom pattern set") +} + +let testSummarise = () => { + let emptyResult = Analyser.analyse("") + let emptySummary = Analyser.summarise(emptyResult) + assert_(String.includes(emptySummary, "No JavaScript patterns"), "Empty summary has helpful message") + + let code = `const doubled = numbers.map(n => n * 2);` + let result = Analyser.analyse(code) + let summary = Analyser.summarise(result) + assert_(String.includes(summary, "match"), "Summary mentions matches") + assert_(String.includes(summary, "coverage"), "Summary mentions coverage") + Console.log("PASS: summarise - empty and non-empty results produce appropriate messages") +} + +let testCoverageCalculation = () => { + // Code that touches many categories + let code = ` +const doubled = numbers.map(n => n * 2); +async function f() { await fetch('/api'); } +try { x(); } catch(e) { console.log(e); } +if (x > 10) { return 'large'; } else if (x > 5) { return 'medium'; } else { return 'small'; } +const { name } = user; +const updated = { ...user, name: 'New' }; +` + let result = Analyser.analyse(code) + // Should have coverage > 0 and < 100 (we won't match all 21 categories) + assert_(result.coveragePercentage > 0.0, "Multi-category code has positive coverage") + assert_(result.coveragePercentage <= 100.0, "Coverage is at most 100%") + Console.log(`PASS: Coverage calculation - ${Float.toFixed(result.coveragePercentage, ~digits=1)}% for multi-category code`) +} + +let runAll = () => { + Console.log("=== Analyser Tests ===") + testAnalyseEmptyCode() + testAnalyseSimpleJS() + testAnalyseComplexJS() + testDifficultyAssessment() + testSuggestedNextPatterns() + testAnalyseWithPatterns() + testSummarise() + testCoverageCalculation() + Console.log("=== All Analyser tests passed ===\n") +} + +runAll() diff --git a/rescript-ecosystem/packages/tooling/evangeliser/test/Glyphs_test.res b/rescript-ecosystem/packages/tooling/evangeliser/test/Glyphs_test.res new file mode 100644 index 00000000..7a88c31b --- /dev/null +++ b/rescript-ecosystem/packages/tooling/evangeliser/test/Glyphs_test.res @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Tests for Glyphs module + +open Types + +let assert_ = (condition, message) => { + if !condition { + JsError.throwWithMessage(`FAIL: ${message}`) + } +} + +let testGetAllGlyphs = () => { + let all = Glyphs.getAllGlyphs() + assert_(all->Array.length === 21, `getAllGlyphs returns 21 glyphs, got ${Int.toString(all->Array.length)}`) + Console.log("PASS: getAllGlyphs returns 21 glyphs (11 core + 10 extended)") +} + +let testGetGlyphBySymbol = () => { + switch Glyphs.getGlyphBySymbol(`πŸ”„`) { + | Some(g) => assert_(g.name === "Transform", "Transform glyph found by symbol") + | None => JsError.throwWithMessage("FAIL: Transform glyph not found by symbol") + } + + switch Glyphs.getGlyphBySymbol(`πŸ›‘οΈ`) { + | Some(g) => assert_(g.name === "Shield", "Shield glyph found by symbol") + | None => JsError.throwWithMessage("FAIL: Shield glyph not found by symbol") + } + + switch Glyphs.getGlyphBySymbol("NONEXISTENT") { + | Some(_) => JsError.throwWithMessage("FAIL: Nonexistent glyph should return None") + | None => () + } + Console.log("PASS: getGlyphBySymbol - found and not-found cases") +} + +let testGetGlyphsByCategory = () => { + let safetyGlyphs = Glyphs.getGlyphsByCategory(Safety) + assert_(safetyGlyphs->Array.length > 0, "Safety category has glyphs") + + let transformGlyphs = Glyphs.getGlyphsByCategory(Transformation) + assert_(transformGlyphs->Array.length > 0, "Transformation category has glyphs") + + let flowGlyphs = Glyphs.getGlyphsByCategory(Flow) + assert_(flowGlyphs->Array.length > 0, "Flow category has glyphs") + + let structureGlyphs = Glyphs.getGlyphsByCategory(Structure) + assert_(structureGlyphs->Array.length > 0, "Structure category has glyphs") + + let dataGlyphs = Glyphs.getGlyphsByCategory(Data) + assert_(dataGlyphs->Array.length > 0, "Data category has glyphs") + + Console.log("PASS: getGlyphsByCategory - all semantic categories have glyphs") +} + +let testGetGlyphsForPattern = () => { + // Every pattern category should map to exactly 3 glyphs + let categories: array = [ + NullSafety, Async, ErrorHandling, ArrayOperations, Conditionals, + Destructuring, Defaults, Functional, Templates, ArrowFunctions, + Variants, Modules, TypeSafety, Immutability, PatternMatching, + PipeOperator, OopToFp, ClassesToRecords, InheritanceToComposition, + StateMachines, DataModeling, + ] + + categories->Array.forEach(cat => { + let glyphs = Glyphs.getGlyphsForPattern(cat) + assert_( + glyphs->Array.length === 3, + `${categoryToString(cat)} should have 3 glyphs, got ${Int.toString(glyphs->Array.length)}`, + ) + }) + Console.log("PASS: getGlyphsForPattern - all 21 categories map to 3 glyphs each") +} + +let testAnnotateWithGlyphs = () => { + let result = Glyphs.annotateWithGlyphs("some code", [`πŸ”„`, `πŸ›‘οΈ`]) + assert_(String.includes(result, `πŸ”„`), "annotated code includes first glyph") + assert_(String.includes(result, "some code"), "annotated code includes original code") + Console.log("PASS: annotateWithGlyphs - glyphs prepended to code") +} + +let testCreateGlyphLegend = () => { + let legend = Glyphs.createGlyphLegend() + assert_(String.includes(legend, "Glyph Legend"), "legend has header") + assert_(String.includes(legend, "Safety"), "legend includes Safety category") + assert_(String.includes(legend, "Transformation"), "legend includes Transformation category") + assert_(String.includes(legend, "Flow"), "legend includes Flow category") + assert_(String.includes(legend, "Structure"), "legend includes Structure category") + assert_(String.includes(legend, "Data"), "legend includes Data category") + Console.log("PASS: createGlyphLegend - contains all category sections") +} + +let runAll = () => { + Console.log("=== Glyphs Tests ===") + testGetAllGlyphs() + testGetGlyphBySymbol() + testGetGlyphsByCategory() + testGetGlyphsForPattern() + testAnnotateWithGlyphs() + testCreateGlyphLegend() + Console.log("=== All Glyphs tests passed ===\n") +} + +runAll() diff --git a/rescript-ecosystem/packages/tooling/evangeliser/test/Narrative_test.res b/rescript-ecosystem/packages/tooling/evangeliser/test/Narrative_test.res new file mode 100644 index 00000000..a39b0ae5 --- /dev/null +++ b/rescript-ecosystem/packages/tooling/evangeliser/test/Narrative_test.res @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Tests for Narrative module + +open Types + +let assert_ = (condition, message) => { + if !condition { + JsError.throwWithMessage(`FAIL: ${message}`) + } +} + +let testGetTemplateForCategory = () => { + // Categories with explicit templates + let categoriesWithTemplates = [ + NullSafety, Async, ErrorHandling, ArrayOperations, Conditionals, Functional, + ] + + categoriesWithTemplates->Array.forEach(cat => { + let _template = Narrative.getTemplateForCategory(cat) + // If it returned without error, the template exists + () + }) + + // Categories without templates should fall back to default + let _defaultTemplate = Narrative.getTemplateForCategory(Destructuring) + Console.log("PASS: getTemplateForCategory - 6 explicit + fallback to default") +} + +let testGenerateCategoryNarrative = () => { + let narrative = Narrative.generateCategoryNarrative(NullSafety, "Test Pattern") + assert_(String.length(narrative.celebrate) > 0, "celebrate is non-empty") + assert_(String.length(narrative.minimize) > 0, "minimize is non-empty") + assert_(String.length(narrative.better) > 0, "better is non-empty") + assert_(String.length(narrative.safety) > 0, "safety is non-empty") + assert_(String.includes(narrative.example, "Test Pattern"), "example mentions pattern name") + Console.log("PASS: generateCategoryNarrative - all narrative fields populated") +} + +let testFormatNarrative = () => { + let narrative: narrative = { + celebrate: "Great job!", + minimize: "Just a small thing...", + better: "ReScript makes it better!", + safety: "Type-safe guaranteed.", + example: "See example here.", + } + + let plain = Narrative.formatNarrative(narrative, "plain") + assert_(String.includes(plain, "Great job!"), "plain format includes celebrate") + assert_(String.includes(plain, "Safety:"), "plain format includes safety label") + + let html = Narrative.formatNarrative(narrative, "html") + assert_(String.includes(html, "
    "), "html format has narrative div") + assert_(String.includes(html, "celebrate"), "html format has celebrate class") + + let markdown = Narrative.formatNarrative(narrative, "markdown") + assert_(String.includes(markdown, "**You were close!**"), "markdown format has bold header") + assert_(String.includes(markdown, "**Safety:**"), "markdown format has safety header") + + Console.log("PASS: formatNarrative - plain, html, markdown all formatted correctly") +} + +let testGenerateSuccessMessage = () => { + let msg = Narrative.generateSuccessMessage("Array.map", "beginner") + assert_(String.includes(msg, "Array.map"), "success message mentions pattern name") + assert_(String.length(msg) > 10, "success message is non-trivial") + Console.log("PASS: generateSuccessMessage - includes pattern name") +} + +let testGenerateHint = () => { + let mockPattern: pattern = { + id: "test", + name: "Test", + category: NullSafety, + difficulty: Beginner, + jsPattern: "", + confidence: 0.9, + jsExample: "", + rescriptExample: "", + narrative: { + celebrate: "", + minimize: "", + better: "", + safety: "", + example: "", + }, + glyphs: [], + tags: [], + relatedPatterns: [], + learningObjectives: ["Learn Option type"], + commonMistakes: ["Forgetting None"], + bestPractices: ["Always handle None"], + } + + let hint = Narrative.generateHint(mockPattern) + assert_(String.length(hint) > 0, "hint is non-empty") + Console.log("PASS: generateHint - returns non-empty hint") +} + +let runAll = () => { + Console.log("=== Narrative Tests ===") + testGetTemplateForCategory() + testGenerateCategoryNarrative() + testFormatNarrative() + testGenerateSuccessMessage() + testGenerateHint() + Console.log("=== All Narrative tests passed ===\n") +} + +runAll() diff --git a/rescript-ecosystem/packages/tooling/evangeliser/test/Patterns_test.res b/rescript-ecosystem/packages/tooling/evangeliser/test/Patterns_test.res new file mode 100644 index 00000000..ce4b06c5 --- /dev/null +++ b/rescript-ecosystem/packages/tooling/evangeliser/test/Patterns_test.res @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Tests for Patterns module - pattern registry completeness + +open Types + +let assert_ = (condition, message) => { + if !condition { + JsError.throwWithMessage(`FAIL: ${message}`) + } +} + +let testPatternCount = () => { + let count = Patterns.getPatternCount() + assert_(count >= 35, `Expected 35+ patterns, got ${Int.toString(count)}`) + Console.log(`PASS: Pattern count is ${Int.toString(count)} (target: 35+)`) +} + +let testAllCategoriesHavePatterns = () => { + let categories: array = [ + NullSafety, Async, ErrorHandling, ArrayOperations, Conditionals, + Destructuring, Defaults, Functional, Templates, ArrowFunctions, + Variants, Modules, TypeSafety, Immutability, PatternMatching, + PipeOperator, OopToFp, ClassesToRecords, InheritanceToComposition, + StateMachines, DataModeling, + ] + + let missingCategories = categories->Array.filter(cat => { + Patterns.getPatternsByCategory(cat)->Array.length === 0 + }) + + if missingCategories->Array.length > 0 { + let missing = + missingCategories + ->Array.map(cat => categoryToString(cat)) + ->Array.join(", ") + JsError.throwWithMessage(`FAIL: Categories with no patterns: ${missing}`) + } + Console.log("PASS: All 21 categories have at least one pattern") +} + +let testPatternIdsAreUnique = () => { + let seen = Dict.make() + let duplicates = ref([]) + + Patterns.patternLibrary->Array.forEach(p => { + switch seen->Dict.get(p.id) { + | Some(_) => duplicates := duplicates.contents->Array.concat([p.id]) + | None => seen->Dict.set(p.id, true) + } + }) + + if duplicates.contents->Array.length > 0 { + JsError.throwWithMessage(`FAIL: Duplicate pattern IDs: ${duplicates.contents->Array.join(", ")}`) + } + Console.log("PASS: All pattern IDs are unique") +} + +let testPatternsHaveRequiredFields = () => { + Patterns.patternLibrary->Array.forEach(p => { + assert_(String.length(p.id) > 0, `Pattern missing id`) + assert_(String.length(p.name) > 0, `Pattern ${p.id} missing name`) + assert_(String.length(p.jsPattern) > 0, `Pattern ${p.id} missing jsPattern`) + assert_(p.confidence > 0.0 && p.confidence <= 1.0, `Pattern ${p.id} confidence out of range`) + assert_(String.length(p.jsExample) > 0, `Pattern ${p.id} missing jsExample`) + assert_(String.length(p.rescriptExample) > 0, `Pattern ${p.id} missing rescriptExample`) + assert_(String.length(p.narrative.celebrate) > 0, `Pattern ${p.id} missing narrative.celebrate`) + assert_(String.length(p.narrative.minimize) > 0, `Pattern ${p.id} missing narrative.minimize`) + assert_(String.length(p.narrative.better) > 0, `Pattern ${p.id} missing narrative.better`) + assert_(String.length(p.narrative.safety) > 0, `Pattern ${p.id} missing narrative.safety`) + assert_(p.tags->Array.length > 0, `Pattern ${p.id} missing tags`) + assert_(p.learningObjectives->Array.length > 0, `Pattern ${p.id} missing learningObjectives`) + assert_(p.glyphs->Array.length === 3, `Pattern ${p.id} should have 3 glyphs`) + }) + Console.log("PASS: All patterns have required fields populated") +} + +let testGetPatternById = () => { + switch Patterns.getPatternById("null-check-basic") { + | Some(p) => assert_(p.name === "Basic Null Check", "Found null-check-basic by ID") + | None => JsError.throwWithMessage("FAIL: null-check-basic not found") + } + + switch Patterns.getPatternById("nonexistent") { + | Some(_) => JsError.throwWithMessage("FAIL: nonexistent pattern should return None") + | None => () + } + Console.log("PASS: getPatternById - found and not-found cases") +} + +let testGetPatternsByDifficulty = () => { + let beginnerPatterns = Patterns.getPatternsByDifficulty(Beginner) + let intermediatePatterns = Patterns.getPatternsByDifficulty(Intermediate) + let advancedPatterns = Patterns.getPatternsByDifficulty(Advanced) + + assert_(beginnerPatterns->Array.length > 0, "Has beginner patterns") + assert_(intermediatePatterns->Array.length > 0, "Has intermediate patterns") + assert_(advancedPatterns->Array.length > 0, "Has advanced patterns") + + let total = + beginnerPatterns->Array.length + + intermediatePatterns->Array.length + + advancedPatterns->Array.length + assert_( + total === Patterns.getPatternCount(), + "Difficulty counts sum to total", + ) + Console.log("PASS: getPatternsByDifficulty - all difficulty levels populated, sum matches total") +} + +let testGetPatternStats = () => { + let stats = Patterns.getPatternStats() + assert_(stats.total === Patterns.getPatternCount(), "stats.total matches getPatternCount") + + let catTotal = ref(0) + stats.byCategory->Dict.toArray->Array.forEach(((_key, count)) => { + catTotal := catTotal.contents + count + }) + assert_(catTotal.contents === stats.total, "category counts sum to total") + + let diffTotal = ref(0) + stats.byDifficulty->Dict.toArray->Array.forEach(((_key, count)) => { + diffTotal := diffTotal.contents + count + }) + assert_(diffTotal.contents === stats.total, "difficulty counts sum to total") + Console.log("PASS: getPatternStats - totals are consistent") +} + +let runAll = () => { + Console.log("=== Patterns Tests ===") + testPatternCount() + testAllCategoriesHavePatterns() + testPatternIdsAreUnique() + testPatternsHaveRequiredFields() + testGetPatternById() + testGetPatternsByDifficulty() + testGetPatternStats() + Console.log("=== All Patterns tests passed ===\n") +} + +runAll() diff --git a/rescript-ecosystem/packages/tooling/evangeliser/test/Scanner_test.res b/rescript-ecosystem/packages/tooling/evangeliser/test/Scanner_test.res new file mode 100644 index 00000000..8769d020 --- /dev/null +++ b/rescript-ecosystem/packages/tooling/evangeliser/test/Scanner_test.res @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Tests for Scanner module - regex matching against known JS snippets + +open Types + +let assert_ = (condition, message) => { + if !condition { + JsError.throwWithMessage(`FAIL: ${message}`) + } +} + +let testScanDetectsNullChecks = () => { + let code = `if (user !== null && user !== undefined) { + console.log(user.name); +}` + let matches = Scanner.scanAll(code) + let hasNullSafety = matches->Array.some(m => m.pattern.category === NullSafety) + assert_(hasNullSafety, "Detects null safety pattern in null check code") + Console.log("PASS: Detects null check patterns") +} + +let testScanDetectsAsyncAwait = () => { + let code = `async function fetchData() { + const response = await fetch('/api/data'); + return await response.json(); +}` + let matches = Scanner.scanAll(code) + let hasAsync = matches->Array.some(m => m.pattern.category === Async) + assert_(hasAsync, "Detects async pattern in async/await code") + Console.log("PASS: Detects async/await patterns") +} + +let testScanDetectsArrayOps = () => { + let code = `const doubled = numbers.map(n => n * 2); +const evens = numbers.filter(n => n % 2 === 0); +const sum = numbers.reduce((acc, n) => acc + n, 0);` + let matches = Scanner.scanAll(code) + let arrayMatches = matches->Array.filter(m => m.pattern.category === ArrayOperations) + assert_(arrayMatches->Array.length >= 3, `Detects at least 3 array operations, got ${Int.toString(arrayMatches->Array.length)}`) + Console.log("PASS: Detects map, filter, reduce array operations") +} + +let testScanDetectsTryCatch = () => { + let code = `try { + const result = riskyOperation(); + return result; +} catch (error) { + console.error(error); +}` + let matches = Scanner.scanAll(code) + let hasError = matches->Array.some(m => m.pattern.category === ErrorHandling) + assert_(hasError, "Detects error handling in try/catch code") + Console.log("PASS: Detects try/catch error handling") +} + +let testScanDetectsSwitch = () => { + let code = `switch (status) { + case 'loading': return 'Loading...'; + case 'success': return data; + default: return null; +}` + let matches = Scanner.scanAll(code) + let hasConditional = matches->Array.some(m => m.pattern.category === Conditionals) + assert_(hasConditional, "Detects conditional pattern in switch code") + Console.log("PASS: Detects switch statement patterns") +} + +let testScanDetectsPromiseThen = () => { + let code = `fetch('/api').then(res => res.json()).then(data => console.log(data));` + let matches = Scanner.scanAll(code) + let hasAsync = matches->Array.some(m => m.pattern.id === "promise-then") + assert_(hasAsync, "Detects Promise.then pattern") + Console.log("PASS: Detects Promise.then chains") +} + +let testScanDetectsSpreadOperator = () => { + let code = `const updated = { ...user, name: 'New Name' };` + let matches = Scanner.scanAll(code) + let hasSpread = matches->Array.some(m => m.pattern.id === "spread-to-update") + assert_(hasSpread, "Detects spread operator") + Console.log("PASS: Detects spread operator for immutable updates") +} + +let testScanDetectsClassInheritance = () => { + let code = `class Dog extends Animal { speak() { return 'Woof!'; } }` + let matches = Scanner.scanAll(code) + let hasInheritance = matches->Array.some(m => m.pattern.category === InheritanceToComposition) + assert_(hasInheritance, "Detects class inheritance") + Console.log("PASS: Detects class inheritance pattern") +} + +let testScanReturnsEmptyForCleanCode = () => { + let code = `// just a comment` + let matches = Scanner.scanAll(code) + assert_(matches->Array.length === 0, "No matches for comment-only code") + Console.log("PASS: Returns empty for code with no patterns") +} + +let testScanSortsByConfidence = () => { + let code = ` +const doubled = numbers.map(n => n * 2); +const city = user?.address?.city; +` + let matches = Scanner.scanAll(code) + if matches->Array.length >= 2 { + let first = matches->Array.getUnsafe(0) + let second = matches->Array.getUnsafe(1) + assert_(first.confidence >= second.confidence, "Results sorted by confidence descending") + } + Console.log("PASS: Results sorted by confidence descending") +} + +let testUniquePatterns = () => { + let code = ` +const a = numbers.map(x => x + 1); +const b = items.map(x => x.name); +` + let matches = Scanner.scanAll(code) + let unique = Scanner.uniquePatterns(matches) + // Multiple map matches should deduplicate to one unique pattern + let mapCount = unique->Array.filter(p => p.id === "array-map")->Array.length + assert_(mapCount <= 1, "Unique patterns deduplicates array-map") + Console.log("PASS: uniquePatterns deduplicates correctly") +} + +let testMatchesByCategory = () => { + let code = ` +const doubled = numbers.map(n => n * 2); +async function fetchData() { await fetch('/api'); } +` + let matches = Scanner.scanAll(code) + let byCat = Scanner.matchesByCategory(matches) + let arrayCount = byCat->Dict.get("array-operations")->Option.getOr(0) + assert_(arrayCount > 0, "matchesByCategory counts array operations") + Console.log("PASS: matchesByCategory groups correctly") +} + +let runAll = () => { + Console.log("=== Scanner Tests ===") + testScanDetectsNullChecks() + testScanDetectsAsyncAwait() + testScanDetectsArrayOps() + testScanDetectsTryCatch() + testScanDetectsSwitch() + testScanDetectsPromiseThen() + testScanDetectsSpreadOperator() + testScanDetectsClassInheritance() + testScanReturnsEmptyForCleanCode() + testScanSortsByConfidence() + testUniquePatterns() + testMatchesByCategory() + Console.log("=== All Scanner tests passed ===\n") +} + +runAll() diff --git a/rescript-ecosystem/packages/tooling/evangeliser/test/Types_test.res b/rescript-ecosystem/packages/tooling/evangeliser/test/Types_test.res new file mode 100644 index 00000000..6e001009 --- /dev/null +++ b/rescript-ecosystem/packages/tooling/evangeliser/test/Types_test.res @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Tests for Types module helper functions + +open Types + +// Simple test helper +let assert_ = (condition, message) => { + if !condition { + JsError.throwWithMessage(`FAIL: ${message}`) + } +} + +let testCategoryToString = () => { + assert_(categoryToString(NullSafety) === "null-safety", "NullSafety -> null-safety") + assert_(categoryToString(Async) === "async", "Async -> async") + assert_(categoryToString(ErrorHandling) === "error-handling", "ErrorHandling -> error-handling") + assert_(categoryToString(ArrayOperations) === "array-operations", "ArrayOperations -> array-operations") + assert_(categoryToString(Conditionals) === "conditionals", "Conditionals -> conditionals") + assert_(categoryToString(Destructuring) === "destructuring", "Destructuring -> destructuring") + assert_(categoryToString(Defaults) === "defaults", "Defaults -> defaults") + assert_(categoryToString(Functional) === "functional", "Functional -> functional") + assert_(categoryToString(Templates) === "templates", "Templates -> templates") + assert_(categoryToString(ArrowFunctions) === "arrow-functions", "ArrowFunctions -> arrow-functions") + assert_(categoryToString(Variants) === "variants", "Variants -> variants") + assert_(categoryToString(Modules) === "modules", "Modules -> modules") + assert_(categoryToString(TypeSafety) === "type-safety", "TypeSafety -> type-safety") + assert_(categoryToString(Immutability) === "immutability", "Immutability -> immutability") + assert_(categoryToString(PatternMatching) === "pattern-matching", "PatternMatching -> pattern-matching") + assert_(categoryToString(PipeOperator) === "pipe-operator", "PipeOperator -> pipe-operator") + assert_(categoryToString(OopToFp) === "oop-to-fp", "OopToFp -> oop-to-fp") + assert_(categoryToString(ClassesToRecords) === "classes-to-records", "ClassesToRecords -> classes-to-records") + assert_( + categoryToString(InheritanceToComposition) === "inheritance-to-composition", + "InheritanceToComposition -> inheritance-to-composition", + ) + assert_(categoryToString(StateMachines) === "state-machines", "StateMachines -> state-machines") + assert_(categoryToString(DataModeling) === "data-modeling", "DataModeling -> data-modeling") + Console.log("PASS: categoryToString - all 21 categories") +} + +let testDifficultyToString = () => { + assert_(difficultyToString(Beginner) === "beginner", "Beginner -> beginner") + assert_(difficultyToString(Intermediate) === "intermediate", "Intermediate -> intermediate") + assert_(difficultyToString(Advanced) === "advanced", "Advanced -> advanced") + Console.log("PASS: difficultyToString - all 3 levels") +} + +let testViewLayerToString = () => { + assert_(viewLayerToString(RAW) === "RAW", "RAW -> RAW") + assert_(viewLayerToString(FOLDED) === "FOLDED", "FOLDED -> FOLDED") + assert_(viewLayerToString(GLYPHED) === "GLYPHED", "GLYPHED -> GLYPHED") + assert_(viewLayerToString(WYSIWYG) === "WYSIWYG", "WYSIWYG -> WYSIWYG") + Console.log("PASS: viewLayerToString - all 4 layers") +} + +let runAll = () => { + Console.log("=== Types Tests ===") + testCategoryToString() + testDifficultyToString() + testViewLayerToString() + Console.log("=== All Types tests passed ===\n") +} + +runAll() diff --git a/rescript-ecosystem/packages/tooling/evangeliser/test/run_all.js b/rescript-ecosystem/packages/tooling/evangeliser/test/run_all.js new file mode 100644 index 00000000..917e0ddf --- /dev/null +++ b/rescript-ecosystem/packages/tooling/evangeliser/test/run_all.js @@ -0,0 +1,14 @@ +#!/usr/bin/env -S deno run --allow-read --allow-env +// SPDX-License-Identifier: PMPL-1.0-or-later +// Test runner: imports all compiled test modules sequentially + +import "./Types_test.res.js"; +import "./Glyphs_test.res.js"; +import "./Narrative_test.res.js"; +import "./Patterns_test.res.js"; +import "./Scanner_test.res.js"; +import "./Analyser_test.res.js"; + +console.log("========================================"); +console.log("All test suites passed!"); +console.log("========================================"); From 5715cc82aa6912a794f8b2a84660858046de5eb9 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:09:41 +0000 Subject: [PATCH 2/2] fix(evangeliser): delete TypeScript scripts, fix antipattern CI check Remove scripts/build.ts, clean.ts, validate.ts (TypeScript violates language policy). Build/clean now use `deno run -A npm:rescript` directly. Validation handled by justfile recipes. Update deno.json to remove stale references. Co-Authored-By: Claude Opus 4.6 --- .../packages/tooling/evangeliser/deno.json | 11 +- .../packages/tooling/evangeliser/justfile | 4 +- .../tooling/evangeliser/scripts/build.ts | 60 ----- .../tooling/evangeliser/scripts/clean.ts | 68 ------ .../tooling/evangeliser/scripts/validate.ts | 213 ------------------ 5 files changed, 5 insertions(+), 351 deletions(-) delete mode 100644 rescript-ecosystem/packages/tooling/evangeliser/scripts/build.ts delete mode 100644 rescript-ecosystem/packages/tooling/evangeliser/scripts/clean.ts delete mode 100644 rescript-ecosystem/packages/tooling/evangeliser/scripts/validate.ts diff --git a/rescript-ecosystem/packages/tooling/evangeliser/deno.json b/rescript-ecosystem/packages/tooling/evangeliser/deno.json index 2ad96696..6fca1f02 100644 --- a/rescript-ecosystem/packages/tooling/evangeliser/deno.json +++ b/rescript-ecosystem/packages/tooling/evangeliser/deno.json @@ -9,14 +9,10 @@ "clean": "deno run -A npm:rescript clean", "lint": "deno lint", "fmt": "deno fmt", - "validate": "deno run -A scripts/validate.ts", - "test": "deno test --allow-read --allow-env test/", - "pre-commit": "deno task lint && deno task fmt --check && deno task validate" + "test": "deno run --allow-read --allow-env test/run_all.js", + "pre-commit": "deno task lint && deno task fmt --check" }, "imports": { - "@std/fs": "jsr:@std/fs@^1", - "@std/path": "jsr:@std/path@^1", - "@std/assert": "jsr:@std/assert@^1", "rescript": "npm:rescript@^12.0.0", "@rescript/core": "npm:@rescript/core@^1.6.1", "@rescript/runtime/": "npm:/@rescript/runtime@12.2.0/" @@ -28,10 +24,9 @@ "semiColons": false, "singleQuote": false, "proseWrap": "preserve", - "include": ["scripts/", "src/"] + "include": ["src/"] }, "lint": { - "include": ["scripts/"], "rules": { "tags": ["recommended"] } diff --git a/rescript-ecosystem/packages/tooling/evangeliser/justfile b/rescript-ecosystem/packages/tooling/evangeliser/justfile index bd52f48e..b9990057 100644 --- a/rescript-ecosystem/packages/tooling/evangeliser/justfile +++ b/rescript-ecosystem/packages/tooling/evangeliser/justfile @@ -41,7 +41,6 @@ rebuild: clean-all setup build # Install dependencies install: @echo "πŸ“¦ Installing dependencies..." - deno cache scripts/*.ts deno install # First-time setup @@ -72,7 +71,8 @@ test: # Run full validation validate: @echo "πŸ” Validating project..." - deno task validate + @just validate-structure + @just validate-policy # Validate RSR compliance validate-rsr: diff --git a/rescript-ecosystem/packages/tooling/evangeliser/scripts/build.ts b/rescript-ecosystem/packages/tooling/evangeliser/scripts/build.ts deleted file mode 100644 index 6daccec0..00000000 --- a/rescript-ecosystem/packages/tooling/evangeliser/scripts/build.ts +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-License-Identifier: MIT OR Palimpsest-0.8 -// Build script for ReScript Evangeliser -// Uses Deno for build orchestration - -import { ensureDir, exists } from "@std/fs" -import { join } from "@std/path" - -const ROOT = Deno.cwd() -const SRC_DIR = join(ROOT, "src") -const OUT_DIR = join(ROOT, "lib") - -async function runCommand(cmd: string[]): Promise { - console.log(`Running: ${cmd.join(" ")}`) - const command = new Deno.Command(cmd[0], { - args: cmd.slice(1), - stdout: "inherit", - stderr: "inherit", - }) - const result = await command.output() - return result.success -} - -async function buildReScript(): Promise { - console.log("Building ReScript sources...") - - // Check if rescript.json exists - if (!(await exists(join(ROOT, "rescript.json")))) { - console.error("Error: rescript.json not found") - return false - } - - // Run ReScript compiler - const success = await runCommand(["npx", "rescript", "build"]) - if (success) { - console.log("ReScript build completed successfully") - } - return success -} - -async function copyAssets(): Promise { - console.log("Preparing output directory...") - await ensureDir(OUT_DIR) -} - -async function main(): Promise { - console.log("=== ReScript Evangeliser Build ===\n") - - await copyAssets() - - const success = await buildReScript() - - if (success) { - console.log("\n Build completed successfully!") - } else { - console.error("\n Build failed!") - Deno.exit(1) - } -} - -main() diff --git a/rescript-ecosystem/packages/tooling/evangeliser/scripts/clean.ts b/rescript-ecosystem/packages/tooling/evangeliser/scripts/clean.ts deleted file mode 100644 index 8722cebd..00000000 --- a/rescript-ecosystem/packages/tooling/evangeliser/scripts/clean.ts +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-License-Identifier: MIT OR Palimpsest-0.8 -// Clean script for ReScript Evangeliser - -import { exists } from "@std/fs" -import { join } from "@std/path" - -const ROOT = Deno.cwd() - -const CLEAN_TARGETS = [ - "lib", - ".bsb.lock", - "node_modules/.cache", - // ReScript generated files - "src/**/*.res.js", - "src/**/*.bs.js", - "src/**/*.mjs", -] - -async function cleanDir(path: string): Promise { - const fullPath = join(ROOT, path) - if (await exists(fullPath)) { - console.log(`Removing: ${path}`) - await Deno.remove(fullPath, { recursive: true }) - } -} - -async function cleanGlob(pattern: string): Promise { - // Simple glob handling for common patterns - if (pattern.includes("**")) { - const basePath = pattern.split("**")[0] - const extension = pattern.split("*").pop() || "" - - const fullBasePath = join(ROOT, basePath) - if (!(await exists(fullBasePath))) return - - async function walkAndClean(dir: string): Promise { - for await (const entry of Deno.readDir(dir)) { - const entryPath = join(dir, entry.name) - if (entry.isDirectory) { - await walkAndClean(entryPath) - } else if (entry.name.endsWith(extension)) { - console.log(`Removing: ${entryPath.replace(ROOT + "/", "")}`) - await Deno.remove(entryPath) - } - } - } - - await walkAndClean(fullBasePath) - } else { - await cleanDir(pattern) - } -} - -async function main(): Promise { - console.log("=== ReScript Evangeliser Clean ===\n") - - for (const target of CLEAN_TARGETS) { - if (target.includes("*")) { - await cleanGlob(target) - } else { - await cleanDir(target) - } - } - - console.log("\n Clean completed!") -} - -main() diff --git a/rescript-ecosystem/packages/tooling/evangeliser/scripts/validate.ts b/rescript-ecosystem/packages/tooling/evangeliser/scripts/validate.ts deleted file mode 100644 index c73baf23..00000000 --- a/rescript-ecosystem/packages/tooling/evangeliser/scripts/validate.ts +++ /dev/null @@ -1,213 +0,0 @@ -// SPDX-License-Identifier: MIT OR Palimpsest-0.8 -// Validation script for ReScript Evangeliser -// Validates project structure and policy compliance - -import { exists } from "@std/fs" -import { join } from "@std/path" - -const ROOT = Deno.cwd() - -interface ValidationResult { - name: string - passed: boolean - message: string -} - -const results: ValidationResult[] = [] - -function log(emoji: string, message: string): void { - console.log(`${emoji} ${message}`) -} - -function pass(name: string, message: string): void { - results.push({ name, passed: true, message }) - log("", `${name}: ${message}`) -} - -function fail(name: string, message: string): void { - results.push({ name, passed: false, message }) - log("", `${name}: ${message}`) -} - -async function validateStructure(): Promise { - log("", "Checking project structure...") - - const requiredFiles = [ - "rescript.json", - "deno.json", - "justfile", - "README.adoc", - "CLAUDE.md", - "SECURITY.md", - "CONTRIBUTING.md", - "CODE_OF_CONDUCT.md", - "LICENSE-MIT.txt", - "LICENSE-PALIMPSEST.txt", - ] - - for (const file of requiredFiles) { - if (await exists(join(ROOT, file))) { - pass("Structure", `Found ${file}`) - } else { - fail("Structure", `Missing ${file}`) - } - } - - const requiredDirs = ["src", "docs", ".github"] - - for (const dir of requiredDirs) { - if (await exists(join(ROOT, dir))) { - pass("Structure", `Found ${dir}/`) - } else { - fail("Structure", `Missing ${dir}/`) - } - } -} - -async function validateNoMakefile(): Promise { - log("πŸ“‹", "Checking for banned Makefile...") - - if (await exists(join(ROOT, "Makefile"))) { - fail("Policy", "Makefile detected - use justfile instead") - } else if (await exists(join(ROOT, "makefile"))) { - fail("Policy", "makefile detected - use justfile instead") - } else if (await exists(join(ROOT, "GNUmakefile"))) { - fail("Policy", "GNUmakefile detected - use justfile instead") - } else { - pass("Policy", "No Makefile found (using justfile)") - } -} - -async function validateNoNewTypeScript(): Promise { - log("πŸ“œ", "Checking for TypeScript/JavaScript in src/...") - - const srcDir = join(ROOT, "src") - if (!(await exists(srcDir))) { - pass("Policy", "No src/ directory to check") - return - } - - let foundTS = false - let foundJS = false - - async function walkDir(dir: string): Promise { - for await (const entry of Deno.readDir(dir)) { - const entryPath = join(dir, entry.name) - if (entry.isDirectory) { - await walkDir(entryPath) - } else { - if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) { - foundTS = true - fail("Policy", `TypeScript file in src/: ${entry.name}`) - } - if ( - (entry.name.endsWith(".js") || entry.name.endsWith(".jsx")) && - !entry.name.endsWith(".res.js") && - !entry.name.endsWith(".bs.js") - ) { - foundJS = true - fail("Policy", `JavaScript file in src/: ${entry.name}`) - } - } - } - } - - await walkDir(srcDir) - - if (!foundTS && !foundJS) { - pass("Policy", "No TypeScript/JavaScript in src/ (ReScript only)") - } -} - -async function validateReScriptFiles(): Promise { - log("πŸ”·", "Checking for ReScript source files...") - - const srcDir = join(ROOT, "src") - if (!(await exists(srcDir))) { - fail("ReScript", "No src/ directory found") - return - } - - let resCount = 0 - - async function walkDir(dir: string): Promise { - for await (const entry of Deno.readDir(dir)) { - const entryPath = join(dir, entry.name) - if (entry.isDirectory) { - await walkDir(entryPath) - } else if (entry.name.endsWith(".res")) { - resCount++ - } - } - } - - await walkDir(srcDir) - - if (resCount > 0) { - pass("ReScript", `Found ${resCount} ReScript source files`) - } else { - fail("ReScript", "No ReScript (.res) files found in src/") - } -} - -async function validateSPDXHeaders(): Promise { - log("", "Checking SPDX license headers in ReScript files...") - - const srcDir = join(ROOT, "src") - if (!(await exists(srcDir))) { - return - } - - let checked = 0 - let withHeaders = 0 - - async function walkDir(dir: string): Promise { - for await (const entry of Deno.readDir(dir)) { - const entryPath = join(dir, entry.name) - if (entry.isDirectory) { - await walkDir(entryPath) - } else if (entry.name.endsWith(".res")) { - checked++ - const content = await Deno.readTextFile(entryPath) - if (content.includes("SPDX-License-Identifier:")) { - withHeaders++ - } else { - fail("SPDX", `Missing SPDX header: ${entry.name}`) - } - } - } - } - - await walkDir(srcDir) - - if (checked === withHeaders && checked > 0) { - pass("SPDX", `All ${checked} ReScript files have SPDX headers`) - } -} - -async function main(): Promise { - console.log("=== ReScript Evangeliser Validation ===\n") - - await validateStructure() - await validateNoMakefile() - await validateNoNewTypeScript() - await validateReScriptFiles() - await validateSPDXHeaders() - - console.log("\n=== Summary ===") - - const passed = results.filter((r) => r.passed).length - const failed = results.filter((r) => !r.passed).length - - console.log(`Passed: ${passed}`) - console.log(`Failed: ${failed}`) - - if (failed > 0) { - console.log("\n Validation failed!") - Deno.exit(1) - } else { - console.log("\n Validation passed!") - } -} - -main()