diff --git a/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Guards.affine b/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Guards.affine index 888f16e3a..9dea4df7d 100644 --- a/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Guards.affine +++ b/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Guards.affine @@ -1,89 +1,97 @@ // SPDX-License-Identifier: MPL-2.0 -// AffineScript port of Guards.res. +// Ported via Harvard Engine (Semantic pass) module Guards; -use Types; -use Session; +// SPDX-License-Identifier: PMPL-1.0-or-later -extern fn str_substring(s: String, start: Int, end: Int) -> String = "string" "substring"; -extern fn str_ends_with(s: String, suffix: String) -> Bool = "string" "endsWith"; -extern fn str_includes(s: String, needle: String) -> Bool = "string" "includes"; -extern fn throw_message(msg: String) -> a = "error" "throw"; - -module AccessGuard { - pub type T = { - session_manager: Session.SessionManager.T, - manifest: Types.AiManifest, +// Access control guard module +module AccessGuard = { + struct t { { + sessionManager: Session.SessionManager.t, + manifest: Types.aiManifest, } - pub fn make(session_manager: Session.SessionManager.T, manifest: Types.AiManifest) -> T { - T { session_manager: session_manager, manifest: manifest } + fn make = (sessionManager: Session.SessionManager.t, manifest: Types.aiManifest): t => { + { + sessionManager, + manifest, + } } - pub fn check_access(guard: T, session_id: String) -> Types.AccessResult { - match Session.SessionManager.get_session(guard.session_manager, session_id) { - None => Types.AccessResult { + // Check if session has access to perform operations + fn checkAccess = (guard: t, sessionId: string): Types.accessResult => { + switch Session.SessionManager.getSession(guard.sessionManager, sessionId) { + | None => { allowed: false, reason: Some("Invalid session ID. Session may have expired."), - }, - Some(session) => { - if !session.acknowledged_manifest { - let hash_preview = str_substring(guard.manifest.hash, 0, 16); - Types.AccessResult { - allowed: false, - reason: Some( - "⚠️ ACCESS DENIED\n\n" - ++ "You must read and acknowledge the AI manifest (AI.a2ml) before " - ++ "accessing any files in this repository.\n\n" - ++ "Call the acknowledge_manifest tool with the manifest hash to proceed.\n\n" - ++ "Expected hash: " ++ hash_preview ++ "..."), - } - } else { - Types.AccessResult { allowed: true, reason: None } + } + | Some(session) => + if !session.acknowledgedManifest { + fn hashPreview = String.substring(guard.manifest.hash, ~start=0, ~end=16) + { + allowed: false, + reason: Some( + "⚠️ ACCESS DENIED\n\n" ++ + "You must read and acknowledge the AI manifest (AI.a2ml) before " ++ + "accessing any files in this repository.\n\n" ++ + "Call the acknowledge_manifest tool with the manifest hash to proceed.\n\n" ++ + `Expected hash: ${hashPreview}...`, + ), } + } else { + {allowed: true, reason: None} } } } - pub fn validate_path(guard: T, path: String) -> Types.AccessResult { - if array_includes(guard.manifest.invariants, "no_scm_duplication") { - let scm_files = ["STATE.scm", "META.scm", "ECOSYSTEM.scm", "AGENTIC.scm", - "NEUROSYM.scm", "PLAYBOOK.scm", "LANGUAGES.scm"]; - let is_violation = false; - let violated_file = "unknown"; - let i = 0; - while i < len(scm_files) { - if str_ends_with(path, scm_files[i]) { - violated_file = scm_files[i]; - if !str_includes(path, ".machine_readable/") { - is_violation = true; - } - } - i = i + 1; - } - if is_violation { - Types.AccessResult { + // Validate that a file path doesn't violate manifest invariants + fn validatePath = (guard: t, path: string): Types.accessResult => { + // Check for SCM file duplication invariant + if Array.includes(guard.manifest.invariants, "no_scm_duplication") { + fn scmFiles = [ + "STATE.scm", + "META.scm", + "ECOSYSTEM.scm", + "AGENTIC.scm", + "NEUROSYM.scm", + "PLAYBOOK.scm", + "LANGUAGES.scm", + ] + + fn isViolation = Array.some(scmFiles, scmFile => { + String.endsWith(path, scmFile) && !String.includes(path, ".machine_readable/") + }) + + if isViolation { + fn violatedFile = + Array.find(scmFiles, scmFile => + String.endsWith(path, scmFile) + )->Belt.Option.getWithDefault("unknown") + + { allowed: false, reason: Some( - "⚠️ INVARIANT VIOLATION\n\n" - ++ "Attempted to access " ++ violated_file ++ " outside of .machine_readable/ directory.\n\n" - ++ "Per AI.a2ml manifest: SCM files MUST be in .machine_readable/ only.\n" - ++ "This prevents duplicate file errors."), + "⚠️ INVARIANT VIOLATION\n\n" ++ + `Attempted to access ${violatedFile} outside of .machine_readable/ directory.\n\n` ++ + "Per AI.a2ml manifest: SCM files MUST be in .machine_readable/ only.\n" ++ "This prevents duplicate file errors.", + ), } } else { - Types.AccessResult { allowed: true, reason: None } + {allowed: true, reason: None} } } else { - Types.AccessResult { allowed: true, reason: None } + {allowed: true, reason: None} } } - pub fn require_acknowledgment(guard: T, session_id: String, operation: String) -> Unit { - let access = check_access(guard, session_id); + // Require acknowledgment before any operation + fn requireAcknowledgment = (guard: t, sessionId: string, operation: string): unit => { + fn access = checkAccess(guard, sessionId) if !access.allowed { - let reason = match access.reason { Some(r) => r, None => "Unknown reason" }; - throw_message("Cannot perform " ++ operation ++ ": " ++ reason) + fn reason = Belt.Option.getWithDefault(access.reason, "Unknown reason") + JsError.throwWithMessage(`Cannot perform ${operation}: ${reason}`) } } } + diff --git a/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Index.affine b/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Index.affine index f5b431dbc..ba39776a3 100644 --- a/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Index.affine +++ b/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Index.affine @@ -1,123 +1,251 @@ // SPDX-License-Identifier: MPL-2.0 -// AffineScript port of Index.res. +// Ported via Harvard Engine (Semantic pass) module Index; -use Types; -use Session; -use Manifest; -use Guards; +// SPDX-License-Identifier: PMPL-1.0-or-later -module MCP { - extern type Server; - extern type Transport; +// MCP SDK External Bindings +module MCP = { + // Server struct + struct server - pub type ToolRequest = { name: String, arguments: Dict } - pub type ToolResponse = { content: [Json] } + // Transport struct + struct transport - extern fn create_server(info: Json) -> Server = "@modelcontextprotocol/sdk/server/index.js" "Server"; - extern fn create_stdio_transport() -> Transport = "@modelcontextprotocol/sdk/server/stdio.js" "StdioServerTransport"; - extern fn connect(s: Server, t: Transport) -> Promise = "mcp" "connect"; - extern fn set_request_handler(s: Server, name: String, handler: fn(ToolRequest) -> Promise) -> Unit = "mcp" "setRequestHandler"; + // Request/Response structs + struct toolRequest { { + name: string, + arguments: dict, + } + + struct toolResponse { {content: array} + + // External functions + @module("@modelcontextprotocol/sdk/server/index.js") @new + external createServer: {"name": string, "version": string} => server = "Server" + + @module("@modelcontextprotocol/sdk/server/stdio.js") @new + external createStdioTransport: unit => transport = "StdioServerTransport" + + @send external connect: (server, transport) => promise = "connect" + + @send + external setRequestHandler: (server, string, toolRequest => promise) => unit = + "setRequestHandler" } -extern fn read_file(path: String, enc: String) -> Promise = "node:fs/promises" "readFile"; -extern fn path_resolve(a: String, b: String) -> String = "node:path" "resolve"; -extern fn path_join(a: String, b: String) -> String = "node:path" "join"; -extern fn env_get(name: String) -> Option = "process" "env"; -extern fn process_cwd() -> String = "process" "cwd"; -extern fn console_log(msg: String) -> Unit = "console" "log"; -extern fn str_to_int(s: String) -> Option = "string" "toInt"; -extern fn json_text_content(text: String) -> Json = "json" "textContent"; -extern fn json_decode_string(j: Json) -> Option = "json" "decodeString"; - -pub fn get_env(name: String, default: String) -> String { - match env_get(name) { Some(v) => v, None => default } +// File system external bindings +@module("node:fs/promises") external readFile: (string, string) => promise = "readFile" +@module("node:fs/promises") external readdir: string => promise> = "readdir" +@module("node:path") external resolve: (string, string) => string = "resolve" +@module("node:path") external join: (string, string) => string = "join" + +// Get environment variable with default +fn getEnv = (name: string, default: string): string => { + fn env: dict = %raw("process.env") + switch Dict.get(env, name) { + | Some(v) => v + | None => default + } } -module GuardianServer { - pub type T = { - server: MCP.Server, - config: Types.GuardianConfig, - session_manager: Session.SessionManager.T, - mut manifests: Dict, +// Main server class +module GuardianServer = { + struct t { { + server: MCP.server, + config: Types.guardianConfig, + sessionManager: Session.SessionManager.t, + mutable manifests: dict, } - pub fn make() -> T { - let config = Types.GuardianConfig { - base_path: get_env("REPOS_PATH", process_cwd()), - strict_mode: get_env("STRICT_MODE", "false") == "true", - session_timeout: match str_to_int(get_env("SESSION_TIMEOUT", "3600000")) { - Some(n) => n, None => 3600000, - }, - }; - let server = MCP.create_server(json_object([ - ("name", json_string("repo-guardian")), - ("version", json_string("0.1.0")), - ])); - T { - server: server, - config: config, - session_manager: Session.SessionManager.make(config), - manifests: dict_empty(), + fn make = (): t => { + fn cwd: unit => string = %raw("() => process.cwd()") + fn config: Types.guardianConfig = { + basePath: getEnv("REPOS_PATH", cwd()), + strictMode: getEnv("STRICT_MODE", "false") == "true", + sessionTimeout: Belt.Int.fromString( + getEnv("SESSION_TIMEOUT", "3600000"), + )->Belt.Option.getWithDefault(3600000), } - } - pub fn handle_get_manifest(guardian: T, repo_path: String) -> Effect[Async] MCP.ToolResponse { - let full_path = path_resolve(guardian.config.base_path, repo_path); - let manifest = await Manifest.parse_manifest(full_path); - dict_set(guardian.manifests, repo_path, manifest); - MCP.ToolResponse { - content: [json_text_content( - "Manifest hash: " ++ manifest.hash - ++ "\n\nYou must acknowledge this manifest with the hash to proceed.")], + fn server = MCP.createServer({"name": "repo-guardian", "version": "0.1.0"}) + fn sessionManager = Session.SessionManager.make(config) + + { + server, + config, + sessionManager, + manifests: Dict.make(), } } - pub fn handle_acknowledge_manifest(guardian: T, repo_path: String, - attestation_hash: String) -> Effect[Async] MCP.ToolResponse { - match dict_get(guardian.manifests, repo_path) { - None => MCP.ToolResponse { content: [json_text_content("ERROR: You must call get_manifest first")] }, - Some(manifest) => { - if !Manifest.validate_attestation(manifest, attestation_hash) { - MCP.ToolResponse { content: [json_text_content("ERROR: Invalid attestation hash")] } - } else { - let session = Session.SessionManager.create_session(guardian.session_manager, repo_path); - let _ = Session.SessionManager.acknowledge_manifest( - guardian.session_manager, session.session_id, manifest, attestation_hash); - MCP.ToolResponse { - content: [json_text_content("✅ Manifest acknowledged! Session ID: " ++ session.session_id)], - } - } + // Handle get_manifest tool + fn handleGetManifest = async (guardian: t, repoPath: string): MCP.toolResponse => { + fn fullPath = resolve(guardian.config.basePath, repoPath) + fn manifest = await Manifest.parseManifest(fullPath) + + // Store manifest + Dict.set(guardian.manifests, repoPath, manifest) + + // Return manifest info + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ( + "text", + JSON.Encode.string( + `Manifest hash: ${manifest.hash}\n\nYou must acknowledge this manifest with the hash to proceed.`, + ), + ), + ]), + ), + ] + + {content: content} + } + + // Handle acknowledge_manifest tool + fn handleAcknowledgeManifest = async ( + guardian: t, + repoPath: string, + attestationHash: string, + ): MCP.toolResponse => { + switch Dict.get(guardian.manifests, repoPath) { + | None => { + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ("text", JSON.Encode.string("ERROR: You must call get_manifest first")), + ]), + ), + ] + {content: content} + } + | Some(manifest) => + if !Manifest.validateAttestation(manifest, attestationHash) { + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ("text", JSON.Encode.string("ERROR: Invalid attestation hash")), + ]), + ), + ] + {content: content} + } else { + // Create session + fn session = Session.SessionManager.createSession(guardian.sessionManager, repoPath) + + // Acknowledge manifest + fn _ = Session.SessionManager.acknowledgeManifest( + guardian.sessionManager, + session.sessionId, + manifest, + attestationHash, + ) + + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ( + "text", + JSON.Encode.string(`✅ Manifest acknowledged! Session ID: ${session.sessionId}`), + ), + ]), + ), + ] + {content: content} } } } - pub fn handle_read_file(guardian: T, session_id: String, path: String) -> Effect[Async] MCP.ToolResponse { - match Session.SessionManager.get_session(guardian.session_manager, session_id) { - None => MCP.ToolResponse { content: [json_text_content("ERROR: Invalid session ID")] }, - Some(session) => { - match dict_get(guardian.manifests, session.repo_path) { - None => MCP.ToolResponse { content: [json_text_content("ERROR: Manifest not found")] }, - Some(manifest) => { - let access_guard = Guards.AccessGuard.make(guardian.session_manager, manifest); - let access_result = Guards.AccessGuard.check_access(access_guard, session_id); - if !access_result.allowed { - let reason = match access_result.reason { Some(r) => r, None => "Unknown" }; - MCP.ToolResponse { content: [json_text_content("ERROR: " ++ reason)] } - } else { - let path_result = Guards.AccessGuard.validate_path(access_guard, path); - if !path_result.allowed { - let reason = match path_result.reason { Some(r) => r, None => "Unknown" }; - MCP.ToolResponse { content: [json_text_content("ERROR: " ++ reason)] } - } else { - let full_path = path_join(guardian.config.base_path, path_join(session.repo_path, path)); - try { - let file_content = await read_file(full_path, "utf-8"); - MCP.ToolResponse { content: [json_text_content(file_content)] } - } catch _e { - MCP.ToolResponse { content: [json_text_content("ERROR: Failed to read file " ++ path)] } - } + // Handle read_file tool + fn handleReadFile = async (guardian: t, sessionId: string, path: string): MCP.toolResponse => { + // Get manifest for session + switch Session.SessionManager.getSession(guardian.sessionManager, sessionId) { + | None => { + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ("text", JSON.Encode.string("ERROR: Invalid session ID")), + ]), + ), + ] + {content: content} + } + | Some(session) => + switch Dict.get(guardian.manifests, session.repoPath) { + | None => { + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ("text", JSON.Encode.string("ERROR: Manifest not found")), + ]), + ), + ] + {content: content} + } + | Some(manifest) => + fn accessGuard = Guards.AccessGuard.make(guardian.sessionManager, manifest) + + // Check access + fn accessResult = Guards.AccessGuard.checkAccess(accessGuard, sessionId) + if !accessResult.allowed { + fn reason = Belt.Option.getWithDefault(accessResult.reason, "Unknown") + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ("text", JSON.Encode.string(`ERROR: ${reason}`)), + ]), + ), + ] + {content: content} + } else { + // Validate path + fn pathResult = Guards.AccessGuard.validatePath(accessGuard, path) + if !pathResult.allowed { + fn reason = Belt.Option.getWithDefault(pathResult.reason, "Unknown") + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ("text", JSON.Encode.string(`ERROR: ${reason}`)), + ]), + ), + ] + {content: content} + } else { + // Read file + fn fullPath = join(guardian.config.basePath, join(session.repoPath, path)) + try { + fn fileContent = await readFile(fullPath, "utf-8") + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ("text", JSON.Encode.string(fileContent)), + ]), + ), + ] + {content: content} + } catch { + | _ => { + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ("text", JSON.Encode.string(`ERROR: Failed to read file ${path}`)), + ]), + ), + ] + {content: content} } } } @@ -126,39 +254,76 @@ module GuardianServer { } } - fn arg_str(args: Dict, key: String) -> String { - match dict_get(args, key) { - Some(j) => match json_decode_string(j) { Some(s) => s, None => "" }, - None => "", - } - } + // Start server + fn start = async (guardian: t): unit => { + // Set up request handlers + MCP.setRequestHandler(guardian.server, "tools/list", async _request => { + fn content = [] + ({content: content}: MCP.toolResponse) + }) + + MCP.setRequestHandler(guardian.server, "tools/call", async request => { + fn name = request.name + fn args = request.arguments - pub fn start(guardian: T) -> Effect[Async] Unit { - MCP.set_request_handler(guardian.server, "tools/list", fn(_request) { - MCP.ToolResponse { content: [] } - }); - - MCP.set_request_handler(guardian.server, "tools/call", fn(request) { - let args = request.arguments; - match request.name { - "get_manifest" => await handle_get_manifest(guardian, arg_str(args, "repoPath")), - "acknowledge_manifest" => - await handle_acknowledge_manifest(guardian, arg_str(args, "repoPath"), arg_str(args, "attestationHash")), - "read_file" => - await handle_read_file(guardian, arg_str(args, "sessionId"), arg_str(args, "path")), - _ => MCP.ToolResponse { content: [json_text_content("Unknown tool: " ++ request.name)] }, + switch name { + | "get_manifest" => { + fn repoPath = + Dict.get(args, "repoPath") + ->Belt.Option.flatMap(JSON.Decode.string) + ->Belt.Option.getWithDefault("") + await handleGetManifest(guardian, repoPath) + } + | "acknowledge_manifest" => { + fn repoPath = + Dict.get(args, "repoPath") + ->Belt.Option.flatMap(JSON.Decode.string) + ->Belt.Option.getWithDefault("") + fn attestationHash = + Dict.get(args, "attestationHash") + ->Belt.Option.flatMap(JSON.Decode.string) + ->Belt.Option.getWithDefault("") + await handleAcknowledgeManifest(guardian, repoPath, attestationHash) + } + | "read_file" => { + fn sessionId = + Dict.get(args, "sessionId") + ->Belt.Option.flatMap(JSON.Decode.string) + ->Belt.Option.getWithDefault("") + fn path = + Dict.get(args, "path") + ->Belt.Option.flatMap(JSON.Decode.string) + ->Belt.Option.getWithDefault("") + await handleReadFile(guardian, sessionId, path) + } + | _ => { + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ("text", JSON.Encode.string(`Unknown tool: ${name}`)), + ]), + ), + ] + {content: content} + } } - }); + }) - let transport = MCP.create_stdio_transport(); - await MCP.connect(guardian.server, transport); - console_log("MCP Repository Guardian started") + // Connect to stdio transport + fn transport = MCP.createStdioTransport() + await MCP.connect(guardian.server, transport) + + Console.log("MCP Repository Guardian started") } } -pub fn main() -> Effect[Async] Unit { - let guardian = GuardianServer.make(); +// Main entry point +fn main = async () => { + fn guardian = GuardianServer.make() await GuardianServer.start(guardian) } -main() +// Run main +main()->ignore + diff --git a/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Manifest.affine b/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Manifest.affine index 9dd35cf55..c2f8208a5 100644 --- a/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Manifest.affine +++ b/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Manifest.affine @@ -1,74 +1,133 @@ // SPDX-License-Identifier: MPL-2.0 -// AffineScript port of Manifest.res. +// Ported via Harvard Engine (Semantic pass) module Manifest; -use Types; - -extern fn read_file(path: String, enc: String) -> Promise = "node:fs/promises" "readFile"; -extern fn path_join(a: String, b: String) -> String = "node:path" "join"; -extern fn sha256_hex(content: String) -> String = "node:crypto" "sha256Hex"; -extern fn re_first_group(pattern: String, content: String) -> Option = "regex" "firstGroupI"; -extern fn re_test_i(pattern: String, content: String) -> Bool = "regex" "testI"; -extern fn date_make() -> Types.Date = "Date" "make"; -extern fn throw_message(msg: String) -> a = "error" "throw"; - -pub fn extract_canonical_locations(content: String) -> Types.CanonicalLocations { - let scm_files = match re_first_group("SCM files.*?`([^`]+)`", content) { - Some(v) => v, None => ".machine_readable/", - }; - let bot_directives = match re_first_group("Bot [Dd]irectives.*?`([^`]+)`", content) { - Some(v) => v, None => ".bot_directives/", - }; - Types.CanonicalLocations { - scm_files: scm_files, - bot_directives: bot_directives, - agent_instructions: [".claude/CLAUDE.md", "AI.a2ml", "0-AI-MANIFEST.a2ml"], +// SPDX-License-Identifier: PMPL-1.0-or-later + +// External Deno APIs +@module("node:fs/promises") external readFile: (string, string) => promise = "readFile" +@module("node:path") external join: (string, string) => string = "join" +@module("node:crypto") external createHash: string => 'a = "createHash" + +struct rec hashObj = { + update: string => hashObj, + digest: string => string, +} + +@send external update: (hashObj, string) => hashObj = "update" +@send external digest: (hashObj, string) => string = "digest" + +// Extract canonical locations from manifest content +fn extractCanonicalLocations = (content: string): Types.canonicalLocations => { + // Use regex to find canonical locations + fn scmMatch = RegExp.exec(/SCM files.*?`([^`]+)`/i, content) + fn botMatch = RegExp.exec(/Bot [Dd]irectives.*?`([^`]+)`/i, content) + + fn scmFiles = switch scmMatch { + | Some(result) => { + fn matches = RegExp.Result.matches(result) + switch matches[1] { + | Some(v) => Belt.Option.getWithDefault(v, ".machine_readable/") + | None => ".machine_readable/" + } + } + | None => ".machine_readable/" + } + + fn botDirectives = switch botMatch { + | Some(result) => { + fn matches = RegExp.Result.matches(result) + switch matches[1] { + | Some(v) => Belt.Option.getWithDefault(v, ".bot_directives/") + | None => ".bot_directives/" + } + } + | None => ".bot_directives/" + } + + fn agentInstructions = [".claude/CLAUDE.md", "AI.a2ml", "0-AI-MANIFEST.a2ml"] + + { + scmFiles, + botDirectives, + agentInstructions, } } -pub fn extract_invariants(content: String) -> [String] { - let invariants = []; - if re_test_i("No SCM file duplication", content) { invariants = invariants ++ ["no_scm_duplication"]; } - if re_test_i("Single source of truth", content) { invariants = invariants ++ ["single_source_of_truth"]; } - if re_test_i("No stale metadata", content) { invariants = invariants ++ ["no_stale_metadata"]; } +// Extract invariants from manifest content +fn extractInvariants = (content: string): array => { + fn invariants = [] + + if String.match(content, /No SCM file duplication/i)->Belt.Option.isSome { + Array.push(invariants, "no_scm_duplication") + } + + if String.match(content, /Single source of truth/i)->Belt.Option.isSome { + Array.push(invariants, "single_source_of_truth") + } + + if String.match(content, /No stale metadata/i)->Belt.Option.isSome { + Array.push(invariants, "no_stale_metadata") + } + invariants } -pub fn parse_manifest(repo_path: String) -> Effect[Async] Types.AiManifest { - let manifest_names = ["0-AI-MANIFEST.a2ml", "AI.a2ml", "!AI.a2ml"]; +// Parse and validate an AI.a2ml manifest file +fn parseManifest = async (repoPath: string): Types.aiManifest => { + fn manifestNames = ["0-AI-MANIFEST.a2ml", "AI.a2ml", "!AI.a2ml"] - fn try_read(names: [String], index: Int) -> Effect[Async] Option<(String, String)> { - if index >= len(names) { + fn rec tryReadManifest = async (names: array, index: int): option<(string, string)> => { + if index >= Array.length(names) { None } else { - let path = path_join(repo_path, names[index]); - try { - let content = await read_file(path, "utf-8"); - Some((content, path)) - } catch _e { - await try_read(names, index + 1) + switch names[index] { + | None => await tryReadManifest(names, index + 1) + | Some(name) => { + fn path = join(repoPath, name) + try { + fn content = await readFile(path, "utf-8") + Some((content, path)) + } catch { + | _ => await tryReadManifest(names, index + 1) + } + } } } } - match await try_read(manifest_names, 0) { - None => { - throw_message("No AI manifest found in " ++ repo_path - ++ ". Expected one of: " ++ str_join(manifest_names, ", ")) + fn result = await tryReadManifest(manifestNames, 0) + + switch result { + | None => { + fn msg = `No AI manifest found in ${repoPath}. Expected one of: ${Array.join( + manifestNames, + ", ", + )}` + JsError.throwWithMessage(msg) } - Some((manifest_content, _manifest_path)) => { - let hash = sha256_hex(manifest_content); - Types.AiManifest { - hash: hash, - canonical_locations: extract_canonical_locations(manifest_content), - invariants: extract_invariants(manifest_content), - parsed_at: date_make(), + | Some((manifestContent, _manifestPath)) => { + // Compute SHA-256 hash + fn hashObj = createHash("sha256") + fn hash = hashObj->update(manifestContent)->digest("hex") + + // Parse manifest structure + fn canonicalLocations = extractCanonicalLocations(manifestContent) + fn invariants = extractInvariants(manifestContent) + + { + hash, + canonicalLocations, + invariants, + parsedAt: Date.make(), } } } } -pub fn validate_attestation(manifest: Types.AiManifest, provided_hash: String) -> Bool { - manifest.hash == provided_hash +// Validate manifest attestation hash +fn validateAttestation = (manifest: Types.aiManifest, providedHash: string): bool => { + manifest.hash === providedHash } + diff --git a/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Session.affine b/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Session.affine index 1e645021f..c1e646311 100644 --- a/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Session.affine +++ b/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Session.affine @@ -1,79 +1,97 @@ // SPDX-License-Identifier: MPL-2.0 -// AffineScript port of Session.res. +// Ported via Harvard Engine (Semantic pass) module Session; -use Types; +// SPDX-License-Identifier: PMPL-1.0-or-later -extern fn random_uuid() -> String = "node:crypto" "randomUUID"; -extern fn set_timeout(cb: fn() -> Unit, ms: Int) -> Unit = "global" "setTimeout"; -extern fn date_make() -> Types.Date = "Date" "make"; +// External Deno/Node crypto API +@module("node:crypto") external randomUUID: unit => string = "randomUUID" +@val external setTimeout: (unit => unit, int) => unit = "setTimeout" -module SessionManager { - pub type T = { - mut sessions: Dict, - config: Types.GuardianConfig, +// Session manager class +module SessionManager = { + struct t { { + mutable sessions: dict, + config: Types.guardianConfig, } - pub fn make(config: Types.GuardianConfig) -> T { - T { sessions: dict_empty(), config: config } + fn make = (config: Types.guardianConfig): t => { + { + sessions: Dict.make(), + config, + } } - pub fn create_session(manager: T, repo_path: String) -> Types.SessionState { - let session = Types.SessionState { - session_id: random_uuid(), - acknowledged_manifest: false, - attestation_hash: None, - acknowledged_at: None, - repo_path: repo_path, - }; - dict_set(manager.sessions, session.session_id, session); - set_timeout(fn() { - dict_set(manager.sessions, session.session_id, session); - }, manager.config.session_timeout); + // Create a new session for an AI agent + fn createSession = (manager: t, repoPath: string): Types.sessionState => { + fn session: Types.sessionState = { + sessionId: randomUUID(), + acknowledgedManifest: false, + attestationHash: None, + acknowledgedAt: None, + repoPath, + } + + Dict.set(manager.sessions, session.sessionId, session) + + // Set timeout to clean up session + setTimeout(() => { + Dict.set(manager.sessions, session.sessionId, session) + () + }, manager.config.sessionTimeout)->ignore + session } - pub fn get_session(manager: T, session_id: String) -> Option { - dict_get(manager.sessions, session_id) + // Get session by ID + fn getSession = (manager: t, sessionId: string): option => { + Dict.get(manager.sessions, sessionId) } - pub fn acknowledge_manifest(manager: T, session_id: String, - manifest: Types.AiManifest, attestation_hash: String) -> Bool { - match dict_get(manager.sessions, session_id) { - None => false, - Some(session) => { - if manifest.hash != attestation_hash { - false - } else { - let updated = Types.SessionState { - ...session, - acknowledged_manifest: true, - attestation_hash: Some(attestation_hash), - acknowledged_at: Some(date_make()), - }; - dict_set(manager.sessions, session_id, updated); - true + // Acknowledge manifest for a session + fn acknowledgeManifest = ( + manager: t, + sessionId: string, + manifest: Types.aiManifest, + attestationHash: string, + ): bool => { + switch Dict.get(manager.sessions, sessionId) { + | None => false + | Some(session) => + if manifest.hash !== attestationHash { + false + } else { + fn updatedSession: Types.sessionState = { + ...session, + acknowledgedManifest: true, + attestationHash: Some(attestationHash), + acknowledgedAt: Some(Date.make()), } + + Dict.set(manager.sessions, sessionId, updatedSession) + true } } } - pub fn is_acknowledged(manager: T, session_id: String) -> Bool { - match dict_get(manager.sessions, session_id) { - None => false, - Some(session) => session.acknowledged_manifest, + // Check if session has acknowledged manifest + fn isAcknowledged = (manager: t, sessionId: string): bool => { + switch Dict.get(manager.sessions, sessionId) { + | None => false + | Some(session) => session.acknowledgedManifest } } - pub fn destroy_session(manager: T, session_id: String) -> Unit { - match dict_get(manager.sessions, session_id) { - Some(s) => dict_set(manager.sessions, session_id, s), - None => {}, - } + // Destroy a session + fn destroySession = (manager: t, sessionId: string): unit => { + Dict.set(manager.sessions, sessionId, Dict.get(manager.sessions, sessionId)->Belt.Option.getExn) + () } - pub fn get_active_sessions(manager: T) -> [Types.SessionState] { - dict_values(manager.sessions) + // Get all active sessions + fn getActiveSessions = (manager: t): array => { + Dict.valuesToArray(manager.sessions) } } + diff --git a/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Types.affine b/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Types.affine index 544afb40d..717dcdb15 100644 --- a/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Types.affine +++ b/0-ai-gatekeeper-protocol/mcp-repo-guardian/lib/ocaml/Types.affine @@ -1,38 +1,43 @@ // SPDX-License-Identifier: MPL-2.0 -// AffineScript port of Types.res. +// Ported via Harvard Engine (Semantic pass) module Types; -extern type Date; +// SPDX-License-Identifier: PMPL-1.0-or-later -pub type CanonicalLocations = { - scm_files: String, - bot_directives: String, - agent_instructions: [String], +// AI.a2ml manifest structure +struct canonicalLocations { { + scmFiles: string, + botDirectives: string, + agentInstructions: array, } -pub type AiManifest = { - hash: String, - canonical_locations: CanonicalLocations, - invariants: [String], - parsed_at: Date, +struct aiManifest { { + hash: string, + canonicalLocations: canonicalLocations, + invariants: array, + parsedAt: Date.t, } -pub type SessionState = { - session_id: String, - acknowledged_manifest: Bool, - attestation_hash: Option, - acknowledged_at: Option, - repo_path: String, +// Session state for an AI agent +struct sessionState { { + sessionId: string, + acknowledgedManifest: bool, + attestationHash: option, + acknowledgedAt: option, + repoPath: string, } -pub type AccessResult = { - allowed: Bool, - reason: Option, +// Access control result +struct accessResult { { + allowed: bool, + reason: option, } -pub type GuardianConfig = { - base_path: String, - strict_mode: Bool, - session_timeout: Int, +// Guardian configuration +struct guardianConfig { { + basePath: string, + strictMode: bool, + sessionTimeout: int, } + diff --git a/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Guards.affine b/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Guards.affine index 888f16e3a..9dea4df7d 100644 --- a/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Guards.affine +++ b/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Guards.affine @@ -1,89 +1,97 @@ // SPDX-License-Identifier: MPL-2.0 -// AffineScript port of Guards.res. +// Ported via Harvard Engine (Semantic pass) module Guards; -use Types; -use Session; +// SPDX-License-Identifier: PMPL-1.0-or-later -extern fn str_substring(s: String, start: Int, end: Int) -> String = "string" "substring"; -extern fn str_ends_with(s: String, suffix: String) -> Bool = "string" "endsWith"; -extern fn str_includes(s: String, needle: String) -> Bool = "string" "includes"; -extern fn throw_message(msg: String) -> a = "error" "throw"; - -module AccessGuard { - pub type T = { - session_manager: Session.SessionManager.T, - manifest: Types.AiManifest, +// Access control guard module +module AccessGuard = { + struct t { { + sessionManager: Session.SessionManager.t, + manifest: Types.aiManifest, } - pub fn make(session_manager: Session.SessionManager.T, manifest: Types.AiManifest) -> T { - T { session_manager: session_manager, manifest: manifest } + fn make = (sessionManager: Session.SessionManager.t, manifest: Types.aiManifest): t => { + { + sessionManager, + manifest, + } } - pub fn check_access(guard: T, session_id: String) -> Types.AccessResult { - match Session.SessionManager.get_session(guard.session_manager, session_id) { - None => Types.AccessResult { + // Check if session has access to perform operations + fn checkAccess = (guard: t, sessionId: string): Types.accessResult => { + switch Session.SessionManager.getSession(guard.sessionManager, sessionId) { + | None => { allowed: false, reason: Some("Invalid session ID. Session may have expired."), - }, - Some(session) => { - if !session.acknowledged_manifest { - let hash_preview = str_substring(guard.manifest.hash, 0, 16); - Types.AccessResult { - allowed: false, - reason: Some( - "⚠️ ACCESS DENIED\n\n" - ++ "You must read and acknowledge the AI manifest (AI.a2ml) before " - ++ "accessing any files in this repository.\n\n" - ++ "Call the acknowledge_manifest tool with the manifest hash to proceed.\n\n" - ++ "Expected hash: " ++ hash_preview ++ "..."), - } - } else { - Types.AccessResult { allowed: true, reason: None } + } + | Some(session) => + if !session.acknowledgedManifest { + fn hashPreview = String.substring(guard.manifest.hash, ~start=0, ~end=16) + { + allowed: false, + reason: Some( + "⚠️ ACCESS DENIED\n\n" ++ + "You must read and acknowledge the AI manifest (AI.a2ml) before " ++ + "accessing any files in this repository.\n\n" ++ + "Call the acknowledge_manifest tool with the manifest hash to proceed.\n\n" ++ + `Expected hash: ${hashPreview}...`, + ), } + } else { + {allowed: true, reason: None} } } } - pub fn validate_path(guard: T, path: String) -> Types.AccessResult { - if array_includes(guard.manifest.invariants, "no_scm_duplication") { - let scm_files = ["STATE.scm", "META.scm", "ECOSYSTEM.scm", "AGENTIC.scm", - "NEUROSYM.scm", "PLAYBOOK.scm", "LANGUAGES.scm"]; - let is_violation = false; - let violated_file = "unknown"; - let i = 0; - while i < len(scm_files) { - if str_ends_with(path, scm_files[i]) { - violated_file = scm_files[i]; - if !str_includes(path, ".machine_readable/") { - is_violation = true; - } - } - i = i + 1; - } - if is_violation { - Types.AccessResult { + // Validate that a file path doesn't violate manifest invariants + fn validatePath = (guard: t, path: string): Types.accessResult => { + // Check for SCM file duplication invariant + if Array.includes(guard.manifest.invariants, "no_scm_duplication") { + fn scmFiles = [ + "STATE.scm", + "META.scm", + "ECOSYSTEM.scm", + "AGENTIC.scm", + "NEUROSYM.scm", + "PLAYBOOK.scm", + "LANGUAGES.scm", + ] + + fn isViolation = Array.some(scmFiles, scmFile => { + String.endsWith(path, scmFile) && !String.includes(path, ".machine_readable/") + }) + + if isViolation { + fn violatedFile = + Array.find(scmFiles, scmFile => + String.endsWith(path, scmFile) + )->Belt.Option.getWithDefault("unknown") + + { allowed: false, reason: Some( - "⚠️ INVARIANT VIOLATION\n\n" - ++ "Attempted to access " ++ violated_file ++ " outside of .machine_readable/ directory.\n\n" - ++ "Per AI.a2ml manifest: SCM files MUST be in .machine_readable/ only.\n" - ++ "This prevents duplicate file errors."), + "⚠️ INVARIANT VIOLATION\n\n" ++ + `Attempted to access ${violatedFile} outside of .machine_readable/ directory.\n\n` ++ + "Per AI.a2ml manifest: SCM files MUST be in .machine_readable/ only.\n" ++ "This prevents duplicate file errors.", + ), } } else { - Types.AccessResult { allowed: true, reason: None } + {allowed: true, reason: None} } } else { - Types.AccessResult { allowed: true, reason: None } + {allowed: true, reason: None} } } - pub fn require_acknowledgment(guard: T, session_id: String, operation: String) -> Unit { - let access = check_access(guard, session_id); + // Require acknowledgment before any operation + fn requireAcknowledgment = (guard: t, sessionId: string, operation: string): unit => { + fn access = checkAccess(guard, sessionId) if !access.allowed { - let reason = match access.reason { Some(r) => r, None => "Unknown reason" }; - throw_message("Cannot perform " ++ operation ++ ": " ++ reason) + fn reason = Belt.Option.getWithDefault(access.reason, "Unknown reason") + JsError.throwWithMessage(`Cannot perform ${operation}: ${reason}`) } } } + diff --git a/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Index.affine b/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Index.affine index f5b431dbc..ba39776a3 100644 --- a/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Index.affine +++ b/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Index.affine @@ -1,123 +1,251 @@ // SPDX-License-Identifier: MPL-2.0 -// AffineScript port of Index.res. +// Ported via Harvard Engine (Semantic pass) module Index; -use Types; -use Session; -use Manifest; -use Guards; +// SPDX-License-Identifier: PMPL-1.0-or-later -module MCP { - extern type Server; - extern type Transport; +// MCP SDK External Bindings +module MCP = { + // Server struct + struct server - pub type ToolRequest = { name: String, arguments: Dict } - pub type ToolResponse = { content: [Json] } + // Transport struct + struct transport - extern fn create_server(info: Json) -> Server = "@modelcontextprotocol/sdk/server/index.js" "Server"; - extern fn create_stdio_transport() -> Transport = "@modelcontextprotocol/sdk/server/stdio.js" "StdioServerTransport"; - extern fn connect(s: Server, t: Transport) -> Promise = "mcp" "connect"; - extern fn set_request_handler(s: Server, name: String, handler: fn(ToolRequest) -> Promise) -> Unit = "mcp" "setRequestHandler"; + // Request/Response structs + struct toolRequest { { + name: string, + arguments: dict, + } + + struct toolResponse { {content: array} + + // External functions + @module("@modelcontextprotocol/sdk/server/index.js") @new + external createServer: {"name": string, "version": string} => server = "Server" + + @module("@modelcontextprotocol/sdk/server/stdio.js") @new + external createStdioTransport: unit => transport = "StdioServerTransport" + + @send external connect: (server, transport) => promise = "connect" + + @send + external setRequestHandler: (server, string, toolRequest => promise) => unit = + "setRequestHandler" } -extern fn read_file(path: String, enc: String) -> Promise = "node:fs/promises" "readFile"; -extern fn path_resolve(a: String, b: String) -> String = "node:path" "resolve"; -extern fn path_join(a: String, b: String) -> String = "node:path" "join"; -extern fn env_get(name: String) -> Option = "process" "env"; -extern fn process_cwd() -> String = "process" "cwd"; -extern fn console_log(msg: String) -> Unit = "console" "log"; -extern fn str_to_int(s: String) -> Option = "string" "toInt"; -extern fn json_text_content(text: String) -> Json = "json" "textContent"; -extern fn json_decode_string(j: Json) -> Option = "json" "decodeString"; - -pub fn get_env(name: String, default: String) -> String { - match env_get(name) { Some(v) => v, None => default } +// File system external bindings +@module("node:fs/promises") external readFile: (string, string) => promise = "readFile" +@module("node:fs/promises") external readdir: string => promise> = "readdir" +@module("node:path") external resolve: (string, string) => string = "resolve" +@module("node:path") external join: (string, string) => string = "join" + +// Get environment variable with default +fn getEnv = (name: string, default: string): string => { + fn env: dict = %raw("process.env") + switch Dict.get(env, name) { + | Some(v) => v + | None => default + } } -module GuardianServer { - pub type T = { - server: MCP.Server, - config: Types.GuardianConfig, - session_manager: Session.SessionManager.T, - mut manifests: Dict, +// Main server class +module GuardianServer = { + struct t { { + server: MCP.server, + config: Types.guardianConfig, + sessionManager: Session.SessionManager.t, + mutable manifests: dict, } - pub fn make() -> T { - let config = Types.GuardianConfig { - base_path: get_env("REPOS_PATH", process_cwd()), - strict_mode: get_env("STRICT_MODE", "false") == "true", - session_timeout: match str_to_int(get_env("SESSION_TIMEOUT", "3600000")) { - Some(n) => n, None => 3600000, - }, - }; - let server = MCP.create_server(json_object([ - ("name", json_string("repo-guardian")), - ("version", json_string("0.1.0")), - ])); - T { - server: server, - config: config, - session_manager: Session.SessionManager.make(config), - manifests: dict_empty(), + fn make = (): t => { + fn cwd: unit => string = %raw("() => process.cwd()") + fn config: Types.guardianConfig = { + basePath: getEnv("REPOS_PATH", cwd()), + strictMode: getEnv("STRICT_MODE", "false") == "true", + sessionTimeout: Belt.Int.fromString( + getEnv("SESSION_TIMEOUT", "3600000"), + )->Belt.Option.getWithDefault(3600000), } - } - pub fn handle_get_manifest(guardian: T, repo_path: String) -> Effect[Async] MCP.ToolResponse { - let full_path = path_resolve(guardian.config.base_path, repo_path); - let manifest = await Manifest.parse_manifest(full_path); - dict_set(guardian.manifests, repo_path, manifest); - MCP.ToolResponse { - content: [json_text_content( - "Manifest hash: " ++ manifest.hash - ++ "\n\nYou must acknowledge this manifest with the hash to proceed.")], + fn server = MCP.createServer({"name": "repo-guardian", "version": "0.1.0"}) + fn sessionManager = Session.SessionManager.make(config) + + { + server, + config, + sessionManager, + manifests: Dict.make(), } } - pub fn handle_acknowledge_manifest(guardian: T, repo_path: String, - attestation_hash: String) -> Effect[Async] MCP.ToolResponse { - match dict_get(guardian.manifests, repo_path) { - None => MCP.ToolResponse { content: [json_text_content("ERROR: You must call get_manifest first")] }, - Some(manifest) => { - if !Manifest.validate_attestation(manifest, attestation_hash) { - MCP.ToolResponse { content: [json_text_content("ERROR: Invalid attestation hash")] } - } else { - let session = Session.SessionManager.create_session(guardian.session_manager, repo_path); - let _ = Session.SessionManager.acknowledge_manifest( - guardian.session_manager, session.session_id, manifest, attestation_hash); - MCP.ToolResponse { - content: [json_text_content("✅ Manifest acknowledged! Session ID: " ++ session.session_id)], - } - } + // Handle get_manifest tool + fn handleGetManifest = async (guardian: t, repoPath: string): MCP.toolResponse => { + fn fullPath = resolve(guardian.config.basePath, repoPath) + fn manifest = await Manifest.parseManifest(fullPath) + + // Store manifest + Dict.set(guardian.manifests, repoPath, manifest) + + // Return manifest info + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ( + "text", + JSON.Encode.string( + `Manifest hash: ${manifest.hash}\n\nYou must acknowledge this manifest with the hash to proceed.`, + ), + ), + ]), + ), + ] + + {content: content} + } + + // Handle acknowledge_manifest tool + fn handleAcknowledgeManifest = async ( + guardian: t, + repoPath: string, + attestationHash: string, + ): MCP.toolResponse => { + switch Dict.get(guardian.manifests, repoPath) { + | None => { + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ("text", JSON.Encode.string("ERROR: You must call get_manifest first")), + ]), + ), + ] + {content: content} + } + | Some(manifest) => + if !Manifest.validateAttestation(manifest, attestationHash) { + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ("text", JSON.Encode.string("ERROR: Invalid attestation hash")), + ]), + ), + ] + {content: content} + } else { + // Create session + fn session = Session.SessionManager.createSession(guardian.sessionManager, repoPath) + + // Acknowledge manifest + fn _ = Session.SessionManager.acknowledgeManifest( + guardian.sessionManager, + session.sessionId, + manifest, + attestationHash, + ) + + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ( + "text", + JSON.Encode.string(`✅ Manifest acknowledged! Session ID: ${session.sessionId}`), + ), + ]), + ), + ] + {content: content} } } } - pub fn handle_read_file(guardian: T, session_id: String, path: String) -> Effect[Async] MCP.ToolResponse { - match Session.SessionManager.get_session(guardian.session_manager, session_id) { - None => MCP.ToolResponse { content: [json_text_content("ERROR: Invalid session ID")] }, - Some(session) => { - match dict_get(guardian.manifests, session.repo_path) { - None => MCP.ToolResponse { content: [json_text_content("ERROR: Manifest not found")] }, - Some(manifest) => { - let access_guard = Guards.AccessGuard.make(guardian.session_manager, manifest); - let access_result = Guards.AccessGuard.check_access(access_guard, session_id); - if !access_result.allowed { - let reason = match access_result.reason { Some(r) => r, None => "Unknown" }; - MCP.ToolResponse { content: [json_text_content("ERROR: " ++ reason)] } - } else { - let path_result = Guards.AccessGuard.validate_path(access_guard, path); - if !path_result.allowed { - let reason = match path_result.reason { Some(r) => r, None => "Unknown" }; - MCP.ToolResponse { content: [json_text_content("ERROR: " ++ reason)] } - } else { - let full_path = path_join(guardian.config.base_path, path_join(session.repo_path, path)); - try { - let file_content = await read_file(full_path, "utf-8"); - MCP.ToolResponse { content: [json_text_content(file_content)] } - } catch _e { - MCP.ToolResponse { content: [json_text_content("ERROR: Failed to read file " ++ path)] } - } + // Handle read_file tool + fn handleReadFile = async (guardian: t, sessionId: string, path: string): MCP.toolResponse => { + // Get manifest for session + switch Session.SessionManager.getSession(guardian.sessionManager, sessionId) { + | None => { + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ("text", JSON.Encode.string("ERROR: Invalid session ID")), + ]), + ), + ] + {content: content} + } + | Some(session) => + switch Dict.get(guardian.manifests, session.repoPath) { + | None => { + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ("text", JSON.Encode.string("ERROR: Manifest not found")), + ]), + ), + ] + {content: content} + } + | Some(manifest) => + fn accessGuard = Guards.AccessGuard.make(guardian.sessionManager, manifest) + + // Check access + fn accessResult = Guards.AccessGuard.checkAccess(accessGuard, sessionId) + if !accessResult.allowed { + fn reason = Belt.Option.getWithDefault(accessResult.reason, "Unknown") + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ("text", JSON.Encode.string(`ERROR: ${reason}`)), + ]), + ), + ] + {content: content} + } else { + // Validate path + fn pathResult = Guards.AccessGuard.validatePath(accessGuard, path) + if !pathResult.allowed { + fn reason = Belt.Option.getWithDefault(pathResult.reason, "Unknown") + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ("text", JSON.Encode.string(`ERROR: ${reason}`)), + ]), + ), + ] + {content: content} + } else { + // Read file + fn fullPath = join(guardian.config.basePath, join(session.repoPath, path)) + try { + fn fileContent = await readFile(fullPath, "utf-8") + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ("text", JSON.Encode.string(fileContent)), + ]), + ), + ] + {content: content} + } catch { + | _ => { + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ("text", JSON.Encode.string(`ERROR: Failed to read file ${path}`)), + ]), + ), + ] + {content: content} } } } @@ -126,39 +254,76 @@ module GuardianServer { } } - fn arg_str(args: Dict, key: String) -> String { - match dict_get(args, key) { - Some(j) => match json_decode_string(j) { Some(s) => s, None => "" }, - None => "", - } - } + // Start server + fn start = async (guardian: t): unit => { + // Set up request handlers + MCP.setRequestHandler(guardian.server, "tools/list", async _request => { + fn content = [] + ({content: content}: MCP.toolResponse) + }) + + MCP.setRequestHandler(guardian.server, "tools/call", async request => { + fn name = request.name + fn args = request.arguments - pub fn start(guardian: T) -> Effect[Async] Unit { - MCP.set_request_handler(guardian.server, "tools/list", fn(_request) { - MCP.ToolResponse { content: [] } - }); - - MCP.set_request_handler(guardian.server, "tools/call", fn(request) { - let args = request.arguments; - match request.name { - "get_manifest" => await handle_get_manifest(guardian, arg_str(args, "repoPath")), - "acknowledge_manifest" => - await handle_acknowledge_manifest(guardian, arg_str(args, "repoPath"), arg_str(args, "attestationHash")), - "read_file" => - await handle_read_file(guardian, arg_str(args, "sessionId"), arg_str(args, "path")), - _ => MCP.ToolResponse { content: [json_text_content("Unknown tool: " ++ request.name)] }, + switch name { + | "get_manifest" => { + fn repoPath = + Dict.get(args, "repoPath") + ->Belt.Option.flatMap(JSON.Decode.string) + ->Belt.Option.getWithDefault("") + await handleGetManifest(guardian, repoPath) + } + | "acknowledge_manifest" => { + fn repoPath = + Dict.get(args, "repoPath") + ->Belt.Option.flatMap(JSON.Decode.string) + ->Belt.Option.getWithDefault("") + fn attestationHash = + Dict.get(args, "attestationHash") + ->Belt.Option.flatMap(JSON.Decode.string) + ->Belt.Option.getWithDefault("") + await handleAcknowledgeManifest(guardian, repoPath, attestationHash) + } + | "read_file" => { + fn sessionId = + Dict.get(args, "sessionId") + ->Belt.Option.flatMap(JSON.Decode.string) + ->Belt.Option.getWithDefault("") + fn path = + Dict.get(args, "path") + ->Belt.Option.flatMap(JSON.Decode.string) + ->Belt.Option.getWithDefault("") + await handleReadFile(guardian, sessionId, path) + } + | _ => { + fn content = [ + JSON.Encode.object( + Dict.fromArray([ + ("struct", JSON.Encode.string("text")), + ("text", JSON.Encode.string(`Unknown tool: ${name}`)), + ]), + ), + ] + {content: content} + } } - }); + }) - let transport = MCP.create_stdio_transport(); - await MCP.connect(guardian.server, transport); - console_log("MCP Repository Guardian started") + // Connect to stdio transport + fn transport = MCP.createStdioTransport() + await MCP.connect(guardian.server, transport) + + Console.log("MCP Repository Guardian started") } } -pub fn main() -> Effect[Async] Unit { - let guardian = GuardianServer.make(); +// Main entry point +fn main = async () => { + fn guardian = GuardianServer.make() await GuardianServer.start(guardian) } -main() +// Run main +main()->ignore + diff --git a/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Manifest.affine b/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Manifest.affine index 9dd35cf55..c2f8208a5 100644 --- a/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Manifest.affine +++ b/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Manifest.affine @@ -1,74 +1,133 @@ // SPDX-License-Identifier: MPL-2.0 -// AffineScript port of Manifest.res. +// Ported via Harvard Engine (Semantic pass) module Manifest; -use Types; - -extern fn read_file(path: String, enc: String) -> Promise = "node:fs/promises" "readFile"; -extern fn path_join(a: String, b: String) -> String = "node:path" "join"; -extern fn sha256_hex(content: String) -> String = "node:crypto" "sha256Hex"; -extern fn re_first_group(pattern: String, content: String) -> Option = "regex" "firstGroupI"; -extern fn re_test_i(pattern: String, content: String) -> Bool = "regex" "testI"; -extern fn date_make() -> Types.Date = "Date" "make"; -extern fn throw_message(msg: String) -> a = "error" "throw"; - -pub fn extract_canonical_locations(content: String) -> Types.CanonicalLocations { - let scm_files = match re_first_group("SCM files.*?`([^`]+)`", content) { - Some(v) => v, None => ".machine_readable/", - }; - let bot_directives = match re_first_group("Bot [Dd]irectives.*?`([^`]+)`", content) { - Some(v) => v, None => ".bot_directives/", - }; - Types.CanonicalLocations { - scm_files: scm_files, - bot_directives: bot_directives, - agent_instructions: [".claude/CLAUDE.md", "AI.a2ml", "0-AI-MANIFEST.a2ml"], +// SPDX-License-Identifier: PMPL-1.0-or-later + +// External Deno APIs +@module("node:fs/promises") external readFile: (string, string) => promise = "readFile" +@module("node:path") external join: (string, string) => string = "join" +@module("node:crypto") external createHash: string => 'a = "createHash" + +struct rec hashObj = { + update: string => hashObj, + digest: string => string, +} + +@send external update: (hashObj, string) => hashObj = "update" +@send external digest: (hashObj, string) => string = "digest" + +// Extract canonical locations from manifest content +fn extractCanonicalLocations = (content: string): Types.canonicalLocations => { + // Use regex to find canonical locations + fn scmMatch = RegExp.exec(/SCM files.*?`([^`]+)`/i, content) + fn botMatch = RegExp.exec(/Bot [Dd]irectives.*?`([^`]+)`/i, content) + + fn scmFiles = switch scmMatch { + | Some(result) => { + fn matches = RegExp.Result.matches(result) + switch matches[1] { + | Some(v) => Belt.Option.getWithDefault(v, ".machine_readable/") + | None => ".machine_readable/" + } + } + | None => ".machine_readable/" + } + + fn botDirectives = switch botMatch { + | Some(result) => { + fn matches = RegExp.Result.matches(result) + switch matches[1] { + | Some(v) => Belt.Option.getWithDefault(v, ".bot_directives/") + | None => ".bot_directives/" + } + } + | None => ".bot_directives/" + } + + fn agentInstructions = [".claude/CLAUDE.md", "AI.a2ml", "0-AI-MANIFEST.a2ml"] + + { + scmFiles, + botDirectives, + agentInstructions, } } -pub fn extract_invariants(content: String) -> [String] { - let invariants = []; - if re_test_i("No SCM file duplication", content) { invariants = invariants ++ ["no_scm_duplication"]; } - if re_test_i("Single source of truth", content) { invariants = invariants ++ ["single_source_of_truth"]; } - if re_test_i("No stale metadata", content) { invariants = invariants ++ ["no_stale_metadata"]; } +// Extract invariants from manifest content +fn extractInvariants = (content: string): array => { + fn invariants = [] + + if String.match(content, /No SCM file duplication/i)->Belt.Option.isSome { + Array.push(invariants, "no_scm_duplication") + } + + if String.match(content, /Single source of truth/i)->Belt.Option.isSome { + Array.push(invariants, "single_source_of_truth") + } + + if String.match(content, /No stale metadata/i)->Belt.Option.isSome { + Array.push(invariants, "no_stale_metadata") + } + invariants } -pub fn parse_manifest(repo_path: String) -> Effect[Async] Types.AiManifest { - let manifest_names = ["0-AI-MANIFEST.a2ml", "AI.a2ml", "!AI.a2ml"]; +// Parse and validate an AI.a2ml manifest file +fn parseManifest = async (repoPath: string): Types.aiManifest => { + fn manifestNames = ["0-AI-MANIFEST.a2ml", "AI.a2ml", "!AI.a2ml"] - fn try_read(names: [String], index: Int) -> Effect[Async] Option<(String, String)> { - if index >= len(names) { + fn rec tryReadManifest = async (names: array, index: int): option<(string, string)> => { + if index >= Array.length(names) { None } else { - let path = path_join(repo_path, names[index]); - try { - let content = await read_file(path, "utf-8"); - Some((content, path)) - } catch _e { - await try_read(names, index + 1) + switch names[index] { + | None => await tryReadManifest(names, index + 1) + | Some(name) => { + fn path = join(repoPath, name) + try { + fn content = await readFile(path, "utf-8") + Some((content, path)) + } catch { + | _ => await tryReadManifest(names, index + 1) + } + } } } } - match await try_read(manifest_names, 0) { - None => { - throw_message("No AI manifest found in " ++ repo_path - ++ ". Expected one of: " ++ str_join(manifest_names, ", ")) + fn result = await tryReadManifest(manifestNames, 0) + + switch result { + | None => { + fn msg = `No AI manifest found in ${repoPath}. Expected one of: ${Array.join( + manifestNames, + ", ", + )}` + JsError.throwWithMessage(msg) } - Some((manifest_content, _manifest_path)) => { - let hash = sha256_hex(manifest_content); - Types.AiManifest { - hash: hash, - canonical_locations: extract_canonical_locations(manifest_content), - invariants: extract_invariants(manifest_content), - parsed_at: date_make(), + | Some((manifestContent, _manifestPath)) => { + // Compute SHA-256 hash + fn hashObj = createHash("sha256") + fn hash = hashObj->update(manifestContent)->digest("hex") + + // Parse manifest structure + fn canonicalLocations = extractCanonicalLocations(manifestContent) + fn invariants = extractInvariants(manifestContent) + + { + hash, + canonicalLocations, + invariants, + parsedAt: Date.make(), } } } } -pub fn validate_attestation(manifest: Types.AiManifest, provided_hash: String) -> Bool { - manifest.hash == provided_hash +// Validate manifest attestation hash +fn validateAttestation = (manifest: Types.aiManifest, providedHash: string): bool => { + manifest.hash === providedHash } + diff --git a/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Session.affine b/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Session.affine index 1e645021f..c1e646311 100644 --- a/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Session.affine +++ b/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Session.affine @@ -1,79 +1,97 @@ // SPDX-License-Identifier: MPL-2.0 -// AffineScript port of Session.res. +// Ported via Harvard Engine (Semantic pass) module Session; -use Types; +// SPDX-License-Identifier: PMPL-1.0-or-later -extern fn random_uuid() -> String = "node:crypto" "randomUUID"; -extern fn set_timeout(cb: fn() -> Unit, ms: Int) -> Unit = "global" "setTimeout"; -extern fn date_make() -> Types.Date = "Date" "make"; +// External Deno/Node crypto API +@module("node:crypto") external randomUUID: unit => string = "randomUUID" +@val external setTimeout: (unit => unit, int) => unit = "setTimeout" -module SessionManager { - pub type T = { - mut sessions: Dict, - config: Types.GuardianConfig, +// Session manager class +module SessionManager = { + struct t { { + mutable sessions: dict, + config: Types.guardianConfig, } - pub fn make(config: Types.GuardianConfig) -> T { - T { sessions: dict_empty(), config: config } + fn make = (config: Types.guardianConfig): t => { + { + sessions: Dict.make(), + config, + } } - pub fn create_session(manager: T, repo_path: String) -> Types.SessionState { - let session = Types.SessionState { - session_id: random_uuid(), - acknowledged_manifest: false, - attestation_hash: None, - acknowledged_at: None, - repo_path: repo_path, - }; - dict_set(manager.sessions, session.session_id, session); - set_timeout(fn() { - dict_set(manager.sessions, session.session_id, session); - }, manager.config.session_timeout); + // Create a new session for an AI agent + fn createSession = (manager: t, repoPath: string): Types.sessionState => { + fn session: Types.sessionState = { + sessionId: randomUUID(), + acknowledgedManifest: false, + attestationHash: None, + acknowledgedAt: None, + repoPath, + } + + Dict.set(manager.sessions, session.sessionId, session) + + // Set timeout to clean up session + setTimeout(() => { + Dict.set(manager.sessions, session.sessionId, session) + () + }, manager.config.sessionTimeout)->ignore + session } - pub fn get_session(manager: T, session_id: String) -> Option { - dict_get(manager.sessions, session_id) + // Get session by ID + fn getSession = (manager: t, sessionId: string): option => { + Dict.get(manager.sessions, sessionId) } - pub fn acknowledge_manifest(manager: T, session_id: String, - manifest: Types.AiManifest, attestation_hash: String) -> Bool { - match dict_get(manager.sessions, session_id) { - None => false, - Some(session) => { - if manifest.hash != attestation_hash { - false - } else { - let updated = Types.SessionState { - ...session, - acknowledged_manifest: true, - attestation_hash: Some(attestation_hash), - acknowledged_at: Some(date_make()), - }; - dict_set(manager.sessions, session_id, updated); - true + // Acknowledge manifest for a session + fn acknowledgeManifest = ( + manager: t, + sessionId: string, + manifest: Types.aiManifest, + attestationHash: string, + ): bool => { + switch Dict.get(manager.sessions, sessionId) { + | None => false + | Some(session) => + if manifest.hash !== attestationHash { + false + } else { + fn updatedSession: Types.sessionState = { + ...session, + acknowledgedManifest: true, + attestationHash: Some(attestationHash), + acknowledgedAt: Some(Date.make()), } + + Dict.set(manager.sessions, sessionId, updatedSession) + true } } } - pub fn is_acknowledged(manager: T, session_id: String) -> Bool { - match dict_get(manager.sessions, session_id) { - None => false, - Some(session) => session.acknowledged_manifest, + // Check if session has acknowledged manifest + fn isAcknowledged = (manager: t, sessionId: string): bool => { + switch Dict.get(manager.sessions, sessionId) { + | None => false + | Some(session) => session.acknowledgedManifest } } - pub fn destroy_session(manager: T, session_id: String) -> Unit { - match dict_get(manager.sessions, session_id) { - Some(s) => dict_set(manager.sessions, session_id, s), - None => {}, - } + // Destroy a session + fn destroySession = (manager: t, sessionId: string): unit => { + Dict.set(manager.sessions, sessionId, Dict.get(manager.sessions, sessionId)->Belt.Option.getExn) + () } - pub fn get_active_sessions(manager: T) -> [Types.SessionState] { - dict_values(manager.sessions) + // Get all active sessions + fn getActiveSessions = (manager: t): array => { + Dict.valuesToArray(manager.sessions) } } + diff --git a/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Types.affine b/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Types.affine index 544afb40d..717dcdb15 100644 --- a/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Types.affine +++ b/0-ai-gatekeeper-protocol/mcp-repo-guardian/src/Types.affine @@ -1,38 +1,43 @@ // SPDX-License-Identifier: MPL-2.0 -// AffineScript port of Types.res. +// Ported via Harvard Engine (Semantic pass) module Types; -extern type Date; +// SPDX-License-Identifier: PMPL-1.0-or-later -pub type CanonicalLocations = { - scm_files: String, - bot_directives: String, - agent_instructions: [String], +// AI.a2ml manifest structure +struct canonicalLocations { { + scmFiles: string, + botDirectives: string, + agentInstructions: array, } -pub type AiManifest = { - hash: String, - canonical_locations: CanonicalLocations, - invariants: [String], - parsed_at: Date, +struct aiManifest { { + hash: string, + canonicalLocations: canonicalLocations, + invariants: array, + parsedAt: Date.t, } -pub type SessionState = { - session_id: String, - acknowledged_manifest: Bool, - attestation_hash: Option, - acknowledged_at: Option, - repo_path: String, +// Session state for an AI agent +struct sessionState { { + sessionId: string, + acknowledgedManifest: bool, + attestationHash: option, + acknowledgedAt: option, + repoPath: string, } -pub type AccessResult = { - allowed: Bool, - reason: Option, +// Access control result +struct accessResult { { + allowed: bool, + reason: option, } -pub type GuardianConfig = { - base_path: String, - strict_mode: Bool, - session_timeout: Int, +// Guardian configuration +struct guardianConfig { { + basePath: string, + strictMode: bool, + sessionTimeout: int, } + diff --git a/a2ml/bindings/deno/src/A2ML.affine b/a2ml/bindings/deno/src/A2ML.affine index bb87290c9..faa8a16a1 100644 --- a/a2ml/bindings/deno/src/A2ML.affine +++ b/a2ml/bindings/deno/src/A2ML.affine @@ -1,33 +1,71 @@ // SPDX-License-Identifier: MPL-2.0 +// Ported via Harvard Engine (Semantic pass) + +module A2ML; + +// SPDX-License-Identifier: PMPL-1.0-or-later // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) // -// A2ML — main module for the A2ML parser library. -// AffineScript port of A2ML.res. Re-exports core types, parser, renderer. +// A2ML — Main module for the A2ML (Attested Markup Language) parser library. +// +// Re-exports the core structs, parser, and renderer for convenient access. +// This module serves as the primary entry point for library consumers. +// +// ## Usage +// +// ```rescript +// open A2ML +// +// fn result = A2ML_Parser.parseA2ML("# Hello\n\nSome text.") +// switch result { +// | Ok(doc) => Console.log(A2ML_Renderer.renderA2ML(doc)) +// | Error(err) => Console.error(A2ML_Types.parseErrorToString(err)) +// } +// ``` -module A2ML; +// Re-export structs for convenience +struct trustLevel { A2ML_Types.trustLevel +struct inline { A2ML_Types.inline +struct directive { A2ML_Types.directive +struct attestation { A2ML_Types.attestation +struct block { A2ML_Types.block +struct document { A2ML_Types.document +struct manifest { A2ML_Types.manifest +struct parseError { A2ML_Types.parseError + +/// Parse an A2ML document from a string. +fn parse = A2ML_Parser.parseA2ML + +/// Parse an A2ML document from a file path. +fn parseFile = A2ML_Parser.parseA2MLFile + +/// Render an A2ML document to text. +fn render = A2ML_Renderer.renderA2ML + +/// Render a single block to text. +fn renderBlock = A2ML_Renderer.renderBlock + +/// Render a single inline element to text. +fn renderInline = A2ML_Renderer.renderInline + +/// Create an empty document. +fn emptyDocument = A2ML_Types.emptyDocument + +/// Create a simple directive. +fn makeDirective = A2ML_Types.makeDirective + +/// Create an attestation. +fn makeAttestation = A2ML_Types.makeAttestation + +/// Extract a manifest from a document. +fn manifestFromDocument = A2ML_Types.manifestFromDocument + +/// Format a parse error as a diagnostic string. +fn parseErrorToString = A2ML_Types.parseErrorToString + +/// Parse a trust level from a string. +fn trustLevelFromString = A2ML_Types.trustLevelFromString + +/// Convert a trust level to its canonical string. +fn trustLevelToString = A2ML_Types.trustLevelToString -use A2ML_Types; -use A2ML_Parser; -use A2ML_Renderer; - -pub type TrustLevel = A2ML_Types.TrustLevel; -pub type Inline = A2ML_Types.Inline; -pub type Directive = A2ML_Types.Directive; -pub type Attestation = A2ML_Types.Attestation; -pub type Block = A2ML_Types.Block; -pub type Document = A2ML_Types.Document; -pub type Manifest = A2ML_Types.Manifest; -pub type ParseError = A2ML_Types.ParseError; - -pub let parse = A2ML_Parser.parse_a2ml; -pub let parse_file = A2ML_Parser.parse_a2ml_file; -pub let render = A2ML_Renderer.render_a2ml; -pub let render_block = A2ML_Renderer.render_block; -pub let render_inline = A2ML_Renderer.render_inline; -pub let empty_document = A2ML_Types.empty_document; -pub let make_directive = A2ML_Types.make_directive; -pub let make_attestation = A2ML_Types.make_attestation; -pub let manifest_from_document = A2ML_Types.manifest_from_document; -pub let parse_error_to_string = A2ML_Types.parse_error_to_string; -pub let trust_level_from_string = A2ML_Types.trust_level_from_string; -pub let trust_level_to_string = A2ML_Types.trust_level_to_string; diff --git a/a2ml/bindings/deno/src/A2ML_Parser.affine b/a2ml/bindings/deno/src/A2ML_Parser.affine index cc338d6b0..cbefa8ef7 100644 --- a/a2ml/bindings/deno/src/A2ML_Parser.affine +++ b/a2ml/bindings/deno/src/A2ML_Parser.affine @@ -1,379 +1,507 @@ // SPDX-License-Identifier: MPL-2.0 +// Ported via Harvard Engine (Semantic pass) + +module A2ML_Parser; + +// SPDX-License-Identifier: PMPL-1.0-or-later // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) // -// A2ML_Parser — parser for A2ML documents. -// AffineScript port of A2ML_Parser.res. +// A2ML_Parser — Parser for A2ML (Attested Markup Language) documents. +// +// Parses the A2ML surface syntax into the structd AST defined in A2ML_Types. +// The parser is line-oriented and processes: +// - Headings (# through #####) +// - Directive blocks (@name(attrs): ... @end) +// - Attestation blocks (!attest ... !end) +// - Inline formatting (**bold**, *italic*, `code`, [link](url), @ref(id)) +// - Bulfn lists (- item) +// - Code blocks (``` fenced blocks) -module A2ML_Parser; +open A2ML_Types + +// --------------------------------------------------------------------------- +// Inline parsing helpers +// --------------------------------------------------------------------------- + +/// Parse a single line of text into inline elements. +/// Handles **bold**, *italic*, `code`, [text](url), and @ref(id). +fn parseInlines = (text: string): array => { + fn result = [] + fn len = text->String.length + fn i = ref(0) + fn buf = ref("") -use A2ML_Types; - -extern fn str_len(s: String) -> Int = "string" "length"; -extern fn str_char_at(s: String, i: Int) -> String = "string" "charAt"; -extern fn str_slice(s: String, start: Int, end: Int) -> String = "string" "slice"; -extern fn str_slice_to_end(s: String, start: Int) -> String = "string" "sliceToEnd"; -extern fn str_starts_with(s: String, p: String) -> Bool = "string" "startsWith"; -extern fn str_index_of(s: String, needle: String) -> Int = "string" "indexOf"; -extern fn str_index_of_from(s: String, needle: String, from: Int) -> Int = "string" "indexOfFrom"; -extern fn str_trim(s: String) -> String = "string" "trim"; -extern fn str_split(s: String, sep: String) -> [String] = "string" "split"; -extern fn str_join(parts: [String], sep: String) -> String = "string" "join"; -extern fn read_file_sync(path: String, enc: String) -> String = "node:fs" "readFileSync"; - -// Parse a single line of text into inline elements. -pub fn parse_inlines(text: String) -> [A2ML_Types.Inline] { - let result = []; - let n = str_len(text); - let i = 0; - let buf = ""; - - fn flush() -> Unit { - if str_len(buf) > 0 { - result = result ++ [A2ML_Types.Text(buf)]; - buf = ""; + // Flush accumulated plain text into the result array + fn flushBuf = () => { + if buf.contents->String.length > 0 { + result->Array.push(Text(buf.contents))->ignore + buf := "" } } - while i < n { - let ch = str_char_at(text, i); - let remaining = str_slice_to_end(text, i); - - if str_starts_with(remaining, "**") { - flush(); - let close_idx = str_index_of_from(text, "**", i + 2); - if close_idx >= 0 { - let inner = str_slice(text, i + 2, close_idx); - result = result ++ [A2ML_Types.Strong([A2ML_Types.Text(inner)])]; - i = close_idx + 2; + while i.contents < len { + fn ch = text->String.charAt(i.contents) + fn remaining = text->String.sliceToEnd(~start=i.contents) + + // **bold** + if remaining->String.startsWith("**") { + flushBuf() + fn closeIdx = text->String.indexOfFrom("**", i.contents + 2) + if closeIdx >= 0 { + fn inner = text->String.slice(~start=i.contents + 2, ~end=closeIdx) + result->Array.push(Strong([Text(inner)]))->ignore + i := closeIdx + 2 } else { - buf = buf ++ ch; - i = i + 1; + buf := buf.contents ++ ch + i := i.contents + 1 } - } else if ch == "*" && !str_starts_with(remaining, "**") { - flush(); - let close_idx = str_index_of_from(text, "*", i + 1); - if close_idx >= 0 { - let inner = str_slice(text, i + 1, close_idx); - result = result ++ [A2ML_Types.Emphasis([A2ML_Types.Text(inner)])]; - i = close_idx + 1; + } + // *italic* + else if ch == "*" && !(remaining->String.startsWith("**")) { + flushBuf() + fn closeIdx = text->String.indexOfFrom("*", i.contents + 1) + if closeIdx >= 0 { + fn inner = text->String.slice(~start=i.contents + 1, ~end=closeIdx) + result->Array.push(Emphasis([Text(inner)]))->ignore + i := closeIdx + 1 } else { - buf = buf ++ ch; - i = i + 1; + buf := buf.contents ++ ch + i := i.contents + 1 } - } else if ch == "`" { - flush(); - let close_idx = str_index_of_from(text, "`", i + 1); - if close_idx >= 0 { - let inner = str_slice(text, i + 1, close_idx); - result = result ++ [A2ML_Types.Code(inner)]; - i = close_idx + 1; + } + // `code` + else if ch == "`" { + flushBuf() + fn closeIdx = text->String.indexOfFrom("`", i.contents + 1) + if closeIdx >= 0 { + fn inner = text->String.slice(~start=i.contents + 1, ~end=closeIdx) + result->Array.push(Code(inner))->ignore + i := closeIdx + 1 } else { - buf = buf ++ ch; - i = i + 1; + buf := buf.contents ++ ch + i := i.contents + 1 } - } else if ch == "[" { - flush(); - let close_bracket = str_index_of_from(text, "]", i + 1); - if close_bracket >= 0 { - let after_bracket = str_char_at(text, close_bracket + 1); - if after_bracket == "(" { - let close_paren = str_index_of_from(text, ")", close_bracket + 2); - if close_paren >= 0 { - let link_text = str_slice(text, i + 1, close_bracket); - let link_url = str_slice(text, close_bracket + 2, close_paren); - result = result ++ [A2ML_Types.Link([A2ML_Types.Text(link_text)], link_url)]; - i = close_paren + 1; + } + // [text](url) + else if ch == "[" { + flushBuf() + fn closeBracket = text->String.indexOfFrom("]", i.contents + 1) + if closeBracket >= 0 { + fn afterBracket = text->String.charAt(closeBracket + 1) + if afterBracket == "(" { + fn closeParen = text->String.indexOfFrom(")", closeBracket + 2) + if closeParen >= 0 { + fn linkText = text->String.slice(~start=i.contents + 1, ~end=closeBracket) + fn linkUrl = text->String.slice(~start=closeBracket + 2, ~end=closeParen) + result->Array.push(Link({content: [Text(linkText)], url: linkUrl}))->ignore + i := closeParen + 1 } else { - buf = buf ++ ch; - i = i + 1; + buf := buf.contents ++ ch + i := i.contents + 1 } } else { - buf = buf ++ ch; - i = i + 1; + buf := buf.contents ++ ch + i := i.contents + 1 } } else { - buf = buf ++ ch; - i = i + 1; + buf := buf.contents ++ ch + i := i.contents + 1 } - } else if str_starts_with(remaining, "@ref(") { - flush(); - let close_paren = str_index_of_from(text, ")", i + 5); - if close_paren >= 0 { - let ref_id = str_slice(text, i + 5, close_paren); - result = result ++ [A2ML_Types.InlineRef(ref_id)]; - i = close_paren + 1; + } + // @ref(id) + else if remaining->String.startsWith("@ref(") { + flushBuf() + fn closeParen = text->String.indexOfFrom(")", i.contents + 5) + if closeParen >= 0 { + fn refId = text->String.slice(~start=i.contents + 5, ~end=closeParen) + result->Array.push(InlineRef(refId))->ignore + i := closeParen + 1 } else { - buf = buf ++ ch; - i = i + 1; + buf := buf.contents ++ ch + i := i.contents + 1 } - } else { - buf = buf ++ ch; - i = i + 1; + } + // Plain text + else { + buf := buf.contents ++ ch + i := i.contents + 1 } } - flush(); + flushBuf() result } -pub fn parse_attributes(attr_str: String) -> [(String, String)] { - if str_len(attr_str) == 0 { +// --------------------------------------------------------------------------- +// Directive attribute parsing +// --------------------------------------------------------------------------- + +/// Parse directive attributes from a parenthesised string like "(key=val, key2=val2)". +fn parseAttributes = (attrStr: string): array<(string, string)> => { + if attrStr->String.length == 0 { [] } else { - let out = []; - let pairs = str_split(attr_str, ","); - let i = 0; - while i < len(pairs) { - let trimmed = str_trim(pairs[i]); - let eq_idx = str_index_of(trimmed, "="); - if eq_idx >= 0 { - let key = str_trim(str_slice(trimmed, 0, eq_idx)); - let value = str_trim(str_slice_to_end(trimmed, eq_idx + 1)); - out = out ++ [(key, value)]; + attrStr + ->String.split(",") + ->Array.filterMap(pair => { + fn trimmed = pair->String.trim + fn eqIdx = trimmed->String.indexOf("=") + if eqIdx >= 0 { + fn key = trimmed->String.slice(~start=0, ~end=eqIdx)->String.trim + fn value = trimmed->String.sliceToEnd(~start=eqIdx + 1)->String.trim + Some((key, value)) + } else { + None } - i = i + 1; - } - out + }) } } -pub type ParserState = { - mut line_index: Int, - lines: [String], - mut blocks: [A2ML_Types.Block], - mut directives: [A2ML_Types.Directive], - mut attestations: [A2ML_Types.Attestation], - mut title: Option, +// --------------------------------------------------------------------------- +// Block-level parser +// --------------------------------------------------------------------------- + +/// Internal state for the line-oriented parser. +struct parserState { { + mutable lineIndex: int, + lines: array, + blocks: array, + directives: array, + attestations: array, + mutable title: option, } -pub fn count_hashes(line: String) -> Int { - let count = 0; - let n = str_len(line); - while count < n && str_char_at(line, count) == "#" { - count = count + 1; +/// Count the number of leading '#' characters on a line. +fn countHashes = (line: string): int => { + fn count = ref(0) + fn len = line->String.length + while count.contents < len && line->String.charAt(count.contents) == "#" { + count := count.contents + 1 } - count + count.contents } -pub fn parse_directive_block(state: ParserState) -> Result { - let start_line = state.line_index; - let line = str_trim(state.lines[start_line]); - let after_at = str_slice_to_end(line, 1); - - let name = ""; - let attributes = []; - let paren_idx = str_index_of(after_at, "("); - if paren_idx >= 0 { - let close_paren_idx = str_index_of(after_at, ")"); - if close_paren_idx > paren_idx { - name = str_trim(str_slice(after_at, 0, paren_idx)); - attributes = parse_attributes(str_slice(after_at, paren_idx + 1, close_paren_idx)); +/// Parse a directive block starting with @name or @name(attrs): +/// Reads lines until @end is encountered. +fn parseDirectiveBlock = (state: parserState): result => { + fn startLine = state.lineIndex + fn line = state.lines->Array.getUnsafe(startLine)->String.trim + + // Extract directive name and optional attributes + // Formats: @name: body or @name(attrs): body or @name:\n multi-line \n @end + fn afterAt = line->String.sliceToEnd(~start=1) + + // Check for parenthesised attributes + fn (name, attributes) = { + fn parenIdx = afterAt->String.indexOf("(") + if parenIdx >= 0 { + fn closeParenIdx = afterAt->String.indexOf(")") + if closeParenIdx > parenIdx { + fn dirName = afterAt->String.slice(~start=0, ~end=parenIdx)->String.trim + fn attrStr = afterAt->String.slice(~start=parenIdx + 1, ~end=closeParenIdx) + (dirName, parseAttributes(attrStr)) + } else { + fn colonIdx = afterAt->String.indexOf(":") + fn dirName = if colonIdx >= 0 { + afterAt->String.slice(~start=0, ~end=colonIdx)->String.trim + } else { + afterAt->String.trim + } + (dirName, []) + } } else { - let colon_idx = str_index_of(after_at, ":"); - name = if colon_idx >= 0 { str_trim(str_slice(after_at, 0, colon_idx)) } else { str_trim(after_at) }; + fn colonIdx = afterAt->String.indexOf(":") + fn dirName = if colonIdx >= 0 { + afterAt->String.slice(~start=0, ~end=colonIdx)->String.trim + } else { + afterAt->String.trim + } + (dirName, []) } - } else { - let colon_idx = str_index_of(after_at, ":"); - name = if colon_idx >= 0 { str_trim(str_slice(after_at, 0, colon_idx)) } else { str_trim(after_at) }; } - let colon_idx = str_index_of(line, ":"); - let inline_body = if colon_idx >= 0 { str_trim(str_slice_to_end(line, colon_idx + 1)) } else { "" }; + // Extract inline body (text after the colon on the same line) + fn colonIdx = line->String.indexOf(":") + fn inlineBody = if colonIdx >= 0 { + line->String.sliceToEnd(~start=colonIdx + 1)->String.trim + } else { + "" + } - if str_len(inline_body) > 0 { - state.line_index = state.line_index + 1; - Ok(A2ML_Types.Directive { name: name, value: inline_body, attributes: attributes }) + // Check if this is a single-line directive (no @end needed) + if inlineBody->String.length > 0 { + state.lineIndex = state.lineIndex + 1 + Ok({name, value: inlineBody, attributes}) } else { - state.line_index = state.line_index + 1; - let body_lines = []; - let found = false; - while state.line_index < len(state.lines) && !found { - let current_line = state.lines[state.line_index]; - if str_trim(current_line) == "@end" { - found = true; - state.line_index = state.line_index + 1; + // Multi-line directive: read until @end + state.lineIndex = state.lineIndex + 1 + fn bodyLines = [] + fn found = ref(false) + while state.lineIndex < state.lines->Array.length && !found.contents { + fn currentLine = state.lines->Array.getUnsafe(state.lineIndex) + if currentLine->String.trim == "@end" { + found := true + state.lineIndex = state.lineIndex + 1 } else { - body_lines = body_lines ++ [current_line]; - state.line_index = state.line_index + 1; + bodyLines->Array.push(currentLine)->ignore + state.lineIndex = state.lineIndex + 1 } } - if found { - Ok(A2ML_Types.Directive { name: name, value: str_join(body_lines, "\n"), attributes: attributes }) + if found.contents { + Ok({name, value: bodyLines->Array.join("\n"), attributes}) } else { - Err(A2ML_Types.UnterminatedDirective(start_line + 1, name)) + Error(UnterminatedDirective({line: startLine + 1, name})) } } } -pub fn parse_attestation_block(state: ParserState) -> Result { - let start_line = state.line_index; - state.line_index = state.line_index + 1; +/// Parse an attestation block starting with !attest. +/// Format: +/// !attest +/// identity: +/// role: +/// trust-level: +/// timestamp: (optional) +/// note: (optional) +/// !end +fn parseAttestationBlock = (state: parserState): result => { + fn startLine = state.lineIndex + state.lineIndex = state.lineIndex + 1 - let identity = ""; let role = ""; let trust_lvl = A2ML_Types.Unverified; - let timestamp = None; let note = None; let found = false; + fn identity = ref("") + fn role = ref("") + fn trustLvl = ref(Unverified) + fn timestamp = ref(None) + fn note = ref(None) + fn found = ref(false) - while state.line_index < len(state.lines) && !found { - let current_line = str_trim(state.lines[state.line_index]); - if current_line == "!end" { - found = true; - state.line_index = state.line_index + 1; + while state.lineIndex < state.lines->Array.length && !found.contents { + fn currentLine = state.lines->Array.getUnsafe(state.lineIndex)->String.trim + if currentLine == "!end" { + found := true + state.lineIndex = state.lineIndex + 1 } else { - let colon_idx = str_index_of(current_line, ":"); - if colon_idx >= 0 { - let key = str_trim(str_slice(current_line, 0, colon_idx)); - let value = str_trim(str_slice_to_end(current_line, colon_idx + 1)); - match key { - "identity" => { identity = value; } - "role" => { role = value; } - "trust-level" => { - match A2ML_Types.trust_level_from_string(value) { Some(lvl) => { trust_lvl = lvl; } None => {} } + fn colonIdx = currentLine->String.indexOf(":") + if colonIdx >= 0 { + fn key = currentLine->String.slice(~start=0, ~end=colonIdx)->String.trim + fn value = currentLine->String.sliceToEnd(~start=colonIdx + 1)->String.trim + switch key { + | "identity" => identity := value + | "role" => role := value + | "trust-level" => + switch trustLevelFromString(value) { + | Some(lvl) => trustLvl := lvl + | None => () // Default to Unverified if unrecognised } - "timestamp" => { timestamp = Some(value); } - "note" => { note = Some(value); } - _ => {} + | "timestamp" => timestamp := Some(value) + | "note" => note := Some(value) + | _ => () // Ignore unknown fields } } - state.line_index = state.line_index + 1; + state.lineIndex = state.lineIndex + 1 } } - if found { - Ok(A2ML_Types.Attestation { - identity: identity, role: role, trust_level: trust_lvl, - timestamp: timestamp, note: note, + if found.contents { + Ok({ + identity: identity.contents, + role: role.contents, + trustLevel: trustLvl.contents, + timestamp: timestamp.contents, + note: note.contents, }) } else { - Err(A2ML_Types.UnexpectedToken(start_line + 1, "unterminated !attest block")) + Error(UnexpectedToken({line: startLine + 1, token: "unterminated !attest block"})) } } -pub fn parse_a2ml(input: String) -> Result { - let trimmed = str_trim(input); - if str_len(trimmed) == 0 { - Err(A2ML_Types.EmptyDocument) +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Parse an A2ML document from a string. +/// +/// Returns either a parseError or the parsed document. +/// +/// ### Example +/// ``` +/// fn result = parseA2ML("# Hello\n\nSome text.\n") +/// ``` +fn parseA2ML = (input: string): result => { + fn trimmed = input->String.trim + if trimmed->String.length == 0 { + Error(EmptyDocument) } else { - let lines = str_split(input, "\n"); - let state = ParserState { - line_index: 0, lines: lines, blocks: [], directives: [], - attestations: [], title: None, - }; - let error = None; - - while state.line_index < len(lines) && (match error { None => true, Some(_) => false }) { - let line = lines[state.line_index]; - let trimmed_line = str_trim(line); - - if str_len(trimmed_line) == 0 { - state.blocks = state.blocks ++ [A2ML_Types.BlankLine]; - state.line_index = state.line_index + 1; - } else if trimmed_line == "---" || trimmed_line == "***" || trimmed_line == "___" { - state.blocks = state.blocks ++ [A2ML_Types.ThematicBreak]; - state.line_index = state.line_index + 1; - } else if str_starts_with(trimmed_line, "```") { - let lang = str_trim(str_slice_to_end(trimmed_line, 3)); - let language = if str_len(lang) > 0 { Some(lang) } else { None }; - state.line_index = state.line_index + 1; - let code_lines = []; - let closed = false; - while state.line_index < len(lines) && !closed { - let code_line = lines[state.line_index]; - if str_starts_with(str_trim(code_line), "```") { - closed = true; - state.line_index = state.line_index + 1; + fn lines = input->String.split("\n") + fn state: parserState = { + lineIndex: 0, + lines, + blocks: [], + directives: [], + attestations: [], + title: None, + } + + fn error = ref(None) + + while state.lineIndex < lines->Array.length && error.contents->Option.isNone { + fn line = lines->Array.getUnsafe(state.lineIndex) + fn trimmedLine = line->String.trim + + // Blank line + if trimmedLine->String.length == 0 { + state.blocks->Array.push(BlankLine)->ignore + state.lineIndex = state.lineIndex + 1 + } + // Thematic break (--- or ***) + else if trimmedLine == "---" || trimmedLine == "***" || trimmedLine == "___" { + state.blocks->Array.push(ThematicBreak)->ignore + state.lineIndex = state.lineIndex + 1 + } + // Fenced code block (```) + else if trimmedLine->String.startsWith("```") { + fn lang = trimmedLine->String.sliceToEnd(~start=3)->String.trim + fn language = if lang->String.length > 0 { + Some(lang) + } else { + None + } + state.lineIndex = state.lineIndex + 1 + fn codeLines = [] + fn closed = ref(false) + while state.lineIndex < lines->Array.length && !closed.contents { + fn codeLine = lines->Array.getUnsafe(state.lineIndex) + if codeLine->String.trim->String.startsWith("```") { + closed := true + state.lineIndex = state.lineIndex + 1 } else { - code_lines = code_lines ++ [code_line]; - state.line_index = state.line_index + 1; + codeLines->Array.push(codeLine)->ignore + state.lineIndex = state.lineIndex + 1 } } - state.blocks = state.blocks ++ [A2ML_Types.CodeBlock(language, str_join(code_lines, "\n"))]; - } else if str_starts_with(trimmed_line, "#") { - let level = count_hashes(trimmed_line); + state.blocks + ->Array.push(CodeBlock({language, content: codeLines->Array.join("\n")})) + ->ignore + } + // Heading (# through #####) + else if trimmedLine->String.startsWith("#") { + fn level = countHashes(trimmedLine) if level >= 1 && level <= 5 { - let heading_text = str_trim(str_slice_to_end(trimmed_line, level)); - let inlines = parse_inlines(heading_text); - if level == 1 && (match state.title { None => true, Some(_) => false }) { - state.title = Some(heading_text); + fn headingText = trimmedLine->String.sliceToEnd(~start=level)->String.trim + fn inlines = parseInlines(headingText) + // Extract title from first H1 heading + if level == 1 && state.title->Option.isNone { + state.title = Some(headingText) } - state.blocks = state.blocks ++ [A2ML_Types.Heading(level, inlines)]; - state.line_index = state.line_index + 1; + state.blocks->Array.push(Heading({level, content: inlines}))->ignore + state.lineIndex = state.lineIndex + 1 } else { - error = Some(A2ML_Types.InvalidHeadingLevel(state.line_index + 1, level)); + error := Some(InvalidHeadingLevel({line: state.lineIndex + 1, level})) } - } else if str_starts_with(trimmed_line, "@") && trimmed_line != "@end" { - match parse_directive_block(state) { - Ok(dir) => { - state.directives = state.directives ++ [dir]; - state.blocks = state.blocks ++ [A2ML_Types.DirectiveBlock(dir)]; - } - Err(err) => { error = Some(err); } + } + // Directive block (@name...) + else if trimmedLine->String.startsWith("@") && trimmedLine != "@end" { + switch parseDirectiveBlock(state) { + | Ok(dir) => + state.directives->Array.push(dir)->ignore + state.blocks->Array.push(DirectiveBlock(dir))->ignore + | Error(err) => error := Some(err) } - } else if str_starts_with(trimmed_line, "!attest") { - match parse_attestation_block(state) { - Ok(att) => { - state.attestations = state.attestations ++ [att]; - state.blocks = state.blocks ++ [A2ML_Types.AttestationBlock(att)]; - } - Err(err) => { error = Some(err); } + } + // Attestation block (!attest) + else if trimmedLine->String.startsWith("!attest") { + switch parseAttestationBlock(state) { + | Ok(att) => + state.attestations->Array.push(att)->ignore + state.blocks->Array.push(AttestationBlock(att))->ignore + | Error(err) => error := Some(err) } - } else if str_starts_with(trimmed_line, "> ") { - let quote_lines = []; - let done = false; - while state.line_index < len(lines) && !done { - let ql = str_trim(lines[state.line_index]); - if str_starts_with(ql, "> ") { - quote_lines = quote_lines ++ [str_slice_to_end(ql, 2)]; - state.line_index = state.line_index + 1; + } + // Block quote (> ...) + else if trimmedLine->String.startsWith("> ") { + fn quoteLines = [] + fn done = ref(false) + while state.lineIndex < lines->Array.length && !done.contents { + fn ql = lines->Array.getUnsafe(state.lineIndex)->String.trim + if ql->String.startsWith("> ") { + quoteLines->Array.push(ql->String.sliceToEnd(~start=2))->ignore + state.lineIndex = state.lineIndex + 1 } else { - done = true; + done := true } } - state.blocks = state.blocks ++ [A2ML_Types.BlockQuote([A2ML_Types.Paragraph(parse_inlines(str_join(quote_lines, "\n")))])]; - } else if str_starts_with(trimmed_line, "- ") || str_starts_with(trimmed_line, "* ") { - let items = []; - let done = false; - while state.line_index < len(lines) && !done { - let list_line = str_trim(lines[state.line_index]); - if str_starts_with(list_line, "- ") || str_starts_with(list_line, "* ") { - items = items ++ [parse_inlines(str_trim(str_slice_to_end(list_line, 2)))]; - state.line_index = state.line_index + 1; + fn quoteText = quoteLines->Array.join("\n") + state.blocks + ->Array.push(BlockQuote([Paragraph(parseInlines(quoteText))])) + ->ignore + } + // Bulfn list (- item) + else if trimmedLine->String.startsWith("- ") || trimmedLine->String.startsWith("* ") { + fn items = [] + fn done = ref(false) + while state.lineIndex < lines->Array.length && !done.contents { + fn listLine = lines->Array.getUnsafe(state.lineIndex)->String.trim + if listLine->String.startsWith("- ") || listLine->String.startsWith("* ") { + fn itemText = listLine->String.sliceToEnd(~start=2)->String.trim + items->Array.push(parseInlines(itemText))->ignore + state.lineIndex = state.lineIndex + 1 } else { - done = true; + done := true } } - state.blocks = state.blocks ++ [A2ML_Types.BulletList(items)]; - } else { - let para_lines = []; - let done = false; - while state.line_index < len(lines) && !done { - let pl = str_trim(lines[state.line_index]); - if str_len(pl) > 0 - && !str_starts_with(pl, "#") && !str_starts_with(pl, "@") - && !str_starts_with(pl, "!attest") && !str_starts_with(pl, "```") - && !str_starts_with(pl, "- ") && !str_starts_with(pl, "* ") - && !str_starts_with(pl, "> ") - && pl != "---" && pl != "***" && pl != "___" { - para_lines = para_lines ++ [pl]; - state.line_index = state.line_index + 1; + state.blocks->Array.push(BulletList(items))->ignore + } + // Paragraph (default) + else { + fn paraLines = [] + fn done = ref(false) + while state.lineIndex < lines->Array.length && !done.contents { + fn pl = lines->Array.getUnsafe(state.lineIndex)->String.trim + if ( + pl->String.length > 0 && + !(pl->String.startsWith("#")) && + !(pl->String.startsWith("@")) && + !(pl->String.startsWith("!attest")) && + !(pl->String.startsWith("```")) && + !(pl->String.startsWith("- ")) && + !(pl->String.startsWith("* ")) && + !(pl->String.startsWith("> ")) && + pl != "---" && + pl != "***" && + pl != "___" + ) { + paraLines->Array.push(pl)->ignore + state.lineIndex = state.lineIndex + 1 } else { - done = true; + done := true } } - state.blocks = state.blocks ++ [A2ML_Types.Paragraph(parse_inlines(str_join(para_lines, " ")))]; + fn paraText = paraLines->Array.join(" ") + state.blocks->Array.push(Paragraph(parseInlines(paraText)))->ignore } } - match error { - Some(err) => Err(err), - None => Ok(A2ML_Types.Document { + switch error.contents { + | Some(err) => Error(err) + | None => + Ok({ title: state.title, directives: state.directives, blocks: state.blocks, attestations: state.attestations, - }), + }) } } } -pub fn parse_a2ml_file(path: String) -> Result { - parse_a2ml(read_file_sync(path, "utf-8")) +/// Parse an A2ML document from a file path (Deno-compatible). +/// Uses Deno.readTextFile under the hood. +/// Returns a Promise resolving to Result. +@module("node:fs") +external readFileSync: (string, string) => string = "readFileSync" + +fn parseA2MLFile = (path: string): result => { + fn content = readFileSync(path, "utf-8") + parseA2ML(content) } + diff --git a/a2ml/bindings/deno/src/A2ML_Renderer.affine b/a2ml/bindings/deno/src/A2ML_Renderer.affine index bba358c3d..f0e04c278 100644 --- a/a2ml/bindings/deno/src/A2ML_Renderer.affine +++ b/a2ml/bindings/deno/src/A2ML_Renderer.affine @@ -1,127 +1,133 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -// -// A2ML_Renderer — render A2ML AST back to A2ML surface syntax. -// AffineScript port of A2ML_Renderer.res. +// Ported via Harvard Engine (Semantic pass) module A2ML_Renderer; -use A2ML_Types; +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// A2ML_Renderer — Render A2ML AST back to A2ML surface syntax. +// +// Converts the structd AST from A2ML_Types into A2ML text format, +// preserving structure and formatting conventions. Produces output +// compatible with the A2ML parser for round-trip fidelity. + +open A2ML_Types -extern fn str_includes(s: String, needle: String) -> Bool = "string" "includes"; +// --------------------------------------------------------------------------- +// Inline rendering +// --------------------------------------------------------------------------- -pub fn render_inline(inl: A2ML_Types.Inline) -> String { - match inl { - Text(t) => t, - Emphasis(children) => "*" ++ render_inlines(children) ++ "*", - Strong(children) => "**" ++ render_inlines(children) ++ "**", - Code(c) => "`" ++ c ++ "`", - Link(content, url) => "[" ++ render_inlines(content) ++ "](" ++ url ++ ")", - InlineRef(ref_id) => "@ref(" ++ ref_id ++ ")", +/// Render a single inline element to A2ML text. +fn rec renderInline = (inl: inline): string => { + switch inl { + | Text(t) => t + | Emphasis(children) => "*" ++ renderInlines(children) ++ "*" + | Strong(children) => "**" ++ renderInlines(children) ++ "**" + | Code(c) => "`" ++ c ++ "`" + | Link({content, url}) => "[" ++ renderInlines(content) ++ "](" ++ url ++ ")" + | InlineRef(refId) => "@ref(" ++ refId ++ ")" } } -pub fn render_inlines(inlines: [A2ML_Types.Inline]) -> String { - let out = ""; - let i = 0; - while i < len(inlines) { - out = out ++ render_inline(inlines[i]); - i = i + 1; - } - out +/// Render a list of inline elements to text. +and renderInlines = (inlines: array): string => { + inlines->Array.map(renderInline)->Array.join("") } -pub fn render_directive(dir: A2ML_Types.Directive) -> String { - let attr_str = if len(dir.attributes) > 0 { - let pairs = []; - let i = 0; - while i < len(dir.attributes) { - let (k, v) = dir.attributes[i]; - pairs = pairs ++ [k ++ "=" ++ v]; - i = i + 1; - } - let joined = ""; - let j = 0; - while j < len(pairs) { - joined = if j == 0 { pairs[j] } else { joined ++ ", " ++ pairs[j] }; - j = j + 1; - } - "(" ++ joined ++ ")" +// --------------------------------------------------------------------------- +// Directive rendering +// --------------------------------------------------------------------------- + +/// Render a directive to A2ML surface syntax. +/// Single-line directives use `@name: value` format. +/// Multi-line directives use `@name:\n...\n@end` format. +fn renderDirective = (dir: directive): string => { + fn attrStr = if dir.attributes->Array.length > 0 { + fn pairs = + dir.attributes + ->Array.map(((k, v)) => k ++ "=" ++ v) + ->Array.join(", ") + "(" ++ pairs ++ ")" } else { "" - }; + } - if str_includes(dir.value, "\n") { - "@" ++ dir.name ++ attr_str ++ ":\n" ++ dir.value ++ "\n@end" + fn hasNewlines = dir.value->String.includes("\n") + if hasNewlines { + "@" ++ dir.name ++ attrStr ++ ":\n" ++ dir.value ++ "\n@end" } else { - "@" ++ dir.name ++ attr_str ++ ": " ++ dir.value + "@" ++ dir.name ++ attrStr ++ ": " ++ dir.value } } -pub fn render_attestation(att: A2ML_Types.Attestation) -> String { - let lines = [ +// --------------------------------------------------------------------------- +// Attestation rendering +// --------------------------------------------------------------------------- + +/// Render an attestation block to A2ML surface syntax. +fn renderAttestation = (att: attestation): string => { + fn lines = [ "!attest", "identity: " ++ att.identity, "role: " ++ att.role, - "trust-level: " ++ A2ML_Types.trust_level_to_string(att.trust_level), - ]; - match att.timestamp { Some(ts) => { lines = lines ++ ["timestamp: " ++ ts]; } None => {} } - match att.note { Some(n) => { lines = lines ++ ["note: " ++ n]; } None => {} } - lines = lines ++ ["!end"]; - let out = ""; - let i = 0; - while i < len(lines) { - out = if i == 0 { lines[i] } else { out ++ "\n" ++ lines[i] }; - i = i + 1; + "trust-level: " ++ trustLevelToString(att.trustLevel), + ] + + switch att.timestamp { + | Some(ts) => lines->Array.push("timestamp: " ++ ts)->ignore + | None => () + } + + switch att.note { + | Some(n) => lines->Array.push("note: " ++ n)->ignore + | None => () } - out + + lines->Array.push("!end")->ignore + lines->Array.join("\n") } -pub fn render_block(blk: A2ML_Types.Block) -> String { - match blk { - Heading(level, content) => { - let hashes = ""; - let i = 0; - while i < level { hashes = hashes ++ "#"; i = i + 1; } - hashes ++ " " ++ render_inlines(content) - } - Paragraph(inlines) => render_inlines(inlines), - CodeBlock(language, content) => { - let lang_tag = match language { Some(l) => l, None => "" }; - "```" ++ lang_tag ++ "\n" ++ content ++ "\n```" - } - DirectiveBlock(dir) => render_directive(dir), - AttestationBlock(att) => render_attestation(att), - ThematicBreak => "---", - BlockQuote(blocks) => { - let parts = []; - let i = 0; - while i < len(blocks) { parts = parts ++ ["> " ++ render_block(blocks[i])]; i = i + 1; } - let out = ""; - let j = 0; - while j < len(parts) { out = if j == 0 { parts[j] } else { out ++ "\n" ++ parts[j] }; j = j + 1; } - out - } - BulletList(items) => { - let parts = []; - let i = 0; - while i < len(items) { parts = parts ++ ["- " ++ render_inlines(items[i])]; i = i + 1; } - let out = ""; - let j = 0; - while j < len(parts) { out = if j == 0 { parts[j] } else { out ++ "\n" ++ parts[j] }; j = j + 1; } - out +// --------------------------------------------------------------------------- +// Block rendering +// --------------------------------------------------------------------------- + +/// Render a single block to A2ML text. +fn rec renderBlock = (blk: block): string => { + switch blk { + | Heading({level, content}) => + fn hashes = Array.make(~length=level, "#")->Array.join("") + hashes ++ " " ++ renderInlines(content) + | Paragraph(inlines) => renderInlines(inlines) + | CodeBlock({language, content}) => + fn langTag = switch language { + | Some(l) => l + | None => "" } - BlankLine => "", + "```" ++ langTag ++ "\n" ++ content ++ "\n```" + | DirectiveBlock(dir) => renderDirective(dir) + | AttestationBlock(att) => renderAttestation(att) + | ThematicBreak => "---" + | BlockQuote(blocks) => + blocks->Array.map(b => "> " ++ renderBlock(b))->Array.join("\n") + | BulletList(items) => + items->Array.map(inlines => "- " ++ renderInlines(inlines))->Array.join("\n") + | BlankLine => "" } } -pub fn render_a2ml(doc: A2ML_Types.Document) -> String { - let out = ""; - let i = 0; - while i < len(doc.blocks) { - let rendered = render_block(doc.blocks[i]); - out = if i == 0 { rendered } else { out ++ "\n" ++ rendered }; - i = i + 1; - } - out ++ "\n" +// --------------------------------------------------------------------------- +// Document rendering +// --------------------------------------------------------------------------- + +/// Render a complete A2ML document to text. +/// +/// ### Example +/// ``` +/// fn doc = { title: Some("Hello"), directives: [], blocks: [...], attestations: [] } +/// fn text = renderA2ML(doc) +/// ``` +fn renderA2ML = (doc: document): string => { + doc.blocks->Array.map(renderBlock)->Array.join("\n") ++ "\n" } + diff --git a/a2ml/bindings/deno/src/A2ML_Types.affine b/a2ml/bindings/deno/src/A2ML_Types.affine index 2699b1dc3..01665b173 100644 --- a/a2ml/bindings/deno/src/A2ML_Types.affine +++ b/a2ml/bindings/deno/src/A2ML_Types.affine @@ -1,119 +1,199 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -// -// A2ML_Types — core data types for A2ML documents. -// AffineScript port of A2ML_Types.res. +// Ported via Harvard Engine (Semantic pass) module A2ML_Types; -extern fn str_lower(s: String) -> String = "string" "toLowerCase"; - -pub type TrustLevel = | Unverified | Automated | Reviewed | Verified - -pub fn trust_level_from_string(s: String) -> Option { - match str_lower(s) { - "unverified" => Some(Unverified), - "automated" => Some(Automated), - "reviewed" => Some(Reviewed), - "verified" => Some(Verified), - _ => None, +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// A2ML_Types — Core data structs for A2ML (Attested Markup Language) documents. +// +// Defines the abstract syntax tree for A2ML documents including document +// structure, block-level elements, inline formatting, directives, and +// attestation provenance records with trust levels. + +// --------------------------------------------------------------------------- +// Trust levels +// --------------------------------------------------------------------------- + +/// The degree of trust associated with an attestation. +/// Forms an ordered scale from unverified content through to formally verified. +struct trustLevel { + | Unverified + | Automated + | Reviewed + | Verified + +/// Parse a trust level from its canonical string representation. +/// Recognised values (case-insensitive): "unverified", "automated", +/// "reviewed", "verified". +fn trustLevelFromString = (s: string): option => { + switch s->String.toLowerCase { + | "unverified" => Some(Unverified) + | "automated" => Some(Automated) + | "reviewed" => Some(Reviewed) + | "verified" => Some(Verified) + | _ => None } } -pub fn trust_level_to_string(level: TrustLevel) -> String { - match level { - Unverified => "unverified", - Automated => "automated", - Reviewed => "reviewed", - Verified => "verified", +/// Return the canonical string representation of a trust level. +fn trustLevelToString = (level: trustLevel): string => { + switch level { + | Unverified => "unverified" + | Automated => "automated" + | Reviewed => "reviewed" + | Verified => "verified" } } -pub type Inline = - | Text(String) - | Emphasis([Inline]) - | Strong([Inline]) - | Code(String) - | Link([Inline], String) - | InlineRef(String) - -pub type Directive = { - name: String, - value: String, - attributes: [(String, String)], +// --------------------------------------------------------------------------- +// Inline-level elements +// --------------------------------------------------------------------------- + +/// An inline-level element within a block. +struct rec inline = + | Text(string) + | Emphasis(array) + | Strong(array) + | Code(string) + | Link({content: array, url: string}) + | InlineRef(string) + +// --------------------------------------------------------------------------- +// Directives +// --------------------------------------------------------------------------- + +/// A machine-readable directive that provides metadata or instructions. +/// Directives begin with `@` in the source text, e.g. +/// `@version 1.0` or `@require trust-level:high`. +struct directive { { + name: string, + value: string, + attributes: array<(string, string)>, } -pub fn make_directive(name: String, value: String) -> Directive { - Directive { name: name, value: value, attributes: [] } +/// Create a simple directive with a name and value, and no attributes. +fn makeDirective = (name: string, value: string): directive => { + name, + value, + attributes: [], } -pub type Attestation = { - identity: String, - role: String, - trust_level: TrustLevel, - timestamp: Option, - note: Option, +// --------------------------------------------------------------------------- +// Attestations +// --------------------------------------------------------------------------- + +/// An attestation record capturing who produced or reviewed content. +/// Attestation blocks start with `!attest` and record identity, +/// role, trust level, and optional timestamp of an author or reviewer. +struct attestation { { + identity: string, + role: string, + trustLevel: trustLevel, + timestamp: option, + note: option, } -pub fn make_attestation(identity: String, role: String, trust_level: TrustLevel) -> Attestation { - Attestation { identity: identity, role: role, trust_level: trust_level, timestamp: None, note: None } +/// Create a new attestation with the minimum required fields. +fn makeAttestation = ( + ~identity: string, + ~role: string, + ~trustLevel: trustLevel, +): attestation => { + identity, + role, + trustLevel, + timestamp: None, + note: None, } -pub type Block = - | Heading(Int, [Inline]) - | Paragraph([Inline]) - | CodeBlock(Option, String) - | DirectiveBlock(Directive) - | AttestationBlock(Attestation) +// --------------------------------------------------------------------------- +// Block-level elements +// --------------------------------------------------------------------------- + +/// A block-level element in an A2ML document. +/// Blocks are separated by blank lines in the source text. +struct rec block = + | Heading({level: int, content: array}) + | Paragraph(array) + | CodeBlock({language: option, content: string}) + | DirectiveBlock(directive) + | AttestationBlock(attestation) | ThematicBreak - | BlockQuote([Block]) - | BulletList([[Inline]]) + | BlockQuote(array) + | BulletList(array>) | BlankLine -pub type Document = { - title: Option, - directives: [Directive], - blocks: [Block], - attestations: [Attestation], +// --------------------------------------------------------------------------- +// Top-level document +// --------------------------------------------------------------------------- + +/// A complete A2ML document, containing metadata and a sequence of blocks. +struct document { { + title: option, + directives: array, + blocks: array, + attestations: array, } -pub fn empty_document() -> Document { - Document { title: None, directives: [], blocks: [], attestations: [] } +/// Create a new, empty document with no title or content. +fn emptyDocument = (): document => { + title: None, + directives: [], + blocks: [], + attestations: [], } -pub type Manifest = { - version: Option, - title: Option, - directives: [Directive], - attestations: [Attestation], +// --------------------------------------------------------------------------- +// Manifest (convenience aggregate) +// --------------------------------------------------------------------------- + +/// A high-level manifest extracted from a parsed A2ML document. +/// Collects directives and attestations for convenient programmatic access. +struct manifest { { + version: option, + title: option, + directives: array, + attestations: array, } -pub fn manifest_from_document(doc: Document) -> Manifest { - let version = None; - let i = 0; - while i < len(doc.directives) { - if doc.directives[i].name == "version" { - version = Some(doc.directives[i].value); - } - i = i + 1; +/// Extract a manifest from a parsed document. +fn manifestFromDocument = (doc: document): manifest => { + fn version = + doc.directives + ->Array.find(d => d.name == "version") + ->Option.map(d => d.value) + + { + version, + title: doc.title, + directives: doc.directives, + attestations: doc.attestations, } - Manifest { version: version, title: doc.title, directives: doc.directives, attestations: doc.attestations } } -pub type ParseError = - | UnterminatedDirective(Int, String) - | InvalidHeadingLevel(Int, Int) - | UnexpectedToken(Int, String) +// --------------------------------------------------------------------------- +// Parse errors +// --------------------------------------------------------------------------- + +/// Errors that can occur during A2ML parsing. +struct parseError { + | UnterminatedDirective({line: int, name: string}) + | InvalidHeadingLevel({line: int, level: int}) + | UnexpectedToken({line: int, token: string}) | EmptyDocument -pub fn parse_error_to_string(err: ParseError) -> String { - match err { - UnterminatedDirective(line, name) => - "error[A2ML]: line " ++ show(line) ++ ": unterminated directive @" ++ name, - InvalidHeadingLevel(line, level) => - "error[A2ML]: line " ++ show(line) ++ ": invalid heading level " ++ show(level) ++ " (must be 1-5)", - UnexpectedToken(line, token) => - "error[A2ML]: line " ++ show(line) ++ ": unexpected token \"" ++ token ++ "\"", - EmptyDocument => "error[A2ML]: document is empty", +/// Format a parse error as a diagnostic string. +fn parseErrorToString = (err: parseError): string => { + switch err { + | UnterminatedDirective({line, name}) => + `error[A2ML]: line ${line->Int.toString}: unterminated directive @${name}` + | InvalidHeadingLevel({line, level}) => + `error[A2ML]: line ${line->Int.toString}: invalid heading level ${level->Int.toString} (must be 1-5)` + | UnexpectedToken({line, token}) => + `error[A2ML]: line ${line->Int.toString}: unexpected token "${token}"` + | EmptyDocument => "error[A2ML]: document is empty" } } + diff --git a/a2ml/prototype/wasm/src/WasmDemo.affine b/a2ml/prototype/wasm/src/WasmDemo.affine index 0cf9577fb..e05838519 100644 --- a/a2ml/prototype/wasm/src/WasmDemo.affine +++ b/a2ml/prototype/wasm/src/WasmDemo.affine @@ -1,33 +1,33 @@ // SPDX-License-Identifier: MPL-2.0 -// -// A2ML WASM prototype. AffineScript port of WasmDemo.res. -// Minimal demo intended for local testing only. +// Ported via Harvard Engine (Semantic pass) module WasmDemo; -extern type WasmInstance; -extern fn compile_to_wasm(wat: String, out: String) -> Promise = "wasm" "compileToWasm"; -extern fn load_module(path: String) -> Promise = "wasm" "loadModule"; -extern fn wasm_add(instance: WasmInstance, a: Int, b: Int) -> Int = "wasm" "exportsAdd"; -extern fn console_log(s: String) -> Unit = "console" "log"; -extern fn int_to_string(n: Int) -> String = "global" "String"; +// SPDX-License-Identifier: PMPL-1.0-or-later -pub fn build_wasm() -> Effect[Async] Unit { - let ok = await compile_to_wasm("fixtures/add.wat", "build/add.wasm"); +// A2ML WASM protostruct using rescript-wasm-runtime. +// This is a minimal demo intended for local testing only. + +// Assumes rescript-wasm-runtime is available locally. +// You can add it to a workspace or compile this in a repo where +// rescript-wasm-runtime's compiled JS is on the module path. + +fn buildWasm = async () => { + fn ok = await Wasm.compileToWasm("fixtures/add.wat", "build/add.wasm", ()) if !ok { - console_log("WASM compile failed") + Js.log("WASM compile failed") } } -pub fn run_wasm() -> Effect[Async] Unit { - let instance = await load_module("build/add.wasm"); - let result = wasm_add(instance, 2, 3); - console_log("add(2,3) = " ++ int_to_string(result)) +fn runWasm = async () => { + fn instance = await Wasm.loadModule("build/add.wasm", ()) + fn exports: Js.t<{. add: (int, int) => int}> = %raw(`instance.exports`) + fn result = exports##add(2, 3) + Js.log(`add(2,3) = ${Int.toString(result)}`) } -pub fn main() -> Effect[Async] Unit { - build_wasm(); - run_wasm() +fn _ = { + fn _ = buildWasm() + fn _ = runWasm() } -main() diff --git a/axel-protocol/src/AxelApp.affine b/axel-protocol/src/AxelApp.affine index 384f0f1aa..fba4909aa 100644 --- a/axel-protocol/src/AxelApp.affine +++ b/axel-protocol/src/AxelApp.affine @@ -1,62 +1,70 @@ // SPDX-License-Identifier: MPL-2.0 -// AXEL Protocol - DNS Label Checker. AffineScript port of AxelApp.res. -// With proven formally verified URL/domain validation. +// Ported via Harvard Engine (Semantic pass) module AxelApp; -pub type LabelRating = +// SPDX-License-Identifier: PMPL-1.0-or-later +// AXEL Protocol - DNS Label Checker +// With proven formally verified URL/domain validation + +// Copy proven bindings locally for compilation +// (In production, these would come from @proven/rescript-bindings package) + +struct labelRating { | NotLabeled | SafeForWork | AdultContent | Explicit -pub type Model = { - domain_to_check: String, - current_rating: LabelRating, - domain_valid: Bool, +struct model { { + domainToCheck: string, + currentRating: labelRating, + domainValid: bool, } -pub type Msg = - | CheckDomain(String) - | SetRating(LabelRating) +struct msg { + | CheckDomain(string) + | SetRating(labelRating) -pub fn init() -> Model { - Model { - domain_to_check: "example.com", - current_rating: NotLabeled, - domain_valid: false, +fn init = () => { + { + domainToCheck: "example.com", + currentRating: NotLabeled, + domainValid: false, } } -extern fn str_contains(s: String, needle: String) -> Bool = "string" "includes"; - -// Validate domain format (proven safe). -pub fn validate_domain(domain: String) -> Bool { - len(domain) > 0 && str_contains(domain, ".") +// Validate domain format (proven safe) +fn validateDomain = (domain: string): bool => { + // Use proven URL validation + // For now, basic check using JavaScript + fn domainLen: int = Obj.magic(domain)["length"] + domainLen > 0 && %raw(`domain.includes(".")`) } -pub fn update(model: Model, msg: Msg) -> Model { - match msg { - CheckDomain(domain) => { - let valid = validate_domain(domain); - Model { ...model, domain_to_check: domain, domain_valid: valid } - } - SetRating(rating) => Model { ...model, current_rating: rating }, +fn update = (model: model, msg: msg) => { + switch msg { + | CheckDomain(domain) => + fn valid = validateDomain(domain) + {...model, domainToCheck: domain, domainValid: valid} + + | SetRating(rating) => + {...model, currentRating: rating} } } -pub fn rating_to_string(rating: LabelRating) -> String { - match rating { - NotLabeled => "Not Labeled", - SafeForWork => "Safe for Work", - AdultContent => "Adult Content (18+)", - Explicit => "Explicit Content", +fn ratingToString = (rating: labelRating): string => { + switch rating { + | NotLabeled => "Not Labeled" + | SafeForWork => "Safe for Work" + | AdultContent => "Adult Content (18+)" + | Explicit => "Explicit Content" } } -pub fn render(model: Model) -> String { - let valid_mark = if model.domain_valid { "✓" } else { "✗" }; - "AXEL Label Checker - Domain: " ++ model.domain_to_check - ++ " | Valid: " ++ valid_mark - ++ " | Rating: " ++ rating_to_string(model.current_rating) +fn render = (model: model) => { + "AXEL Label Checker - Domain: " ++ model.domainToCheck ++ + " | Valid: " ++ (model.domainValid ? "✓" : "✗") ++ + " | Rating: " ++ ratingToString(model.currentRating) } + diff --git a/axel-protocol/src/AxelSts.affine b/axel-protocol/src/AxelSts.affine index 47ca357ba..eaa5d577e 100644 --- a/axel-protocol/src/AxelSts.affine +++ b/axel-protocol/src/AxelSts.affine @@ -1,130 +1,96 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell -// -// AXEL Protocol - DNS TXT Record Parser (Strict). -// AffineScript port of AxelSts.res. -// -// Parses AXEL DNS TXT record payloads (RDATA only, not full RR lines). -// Fails closed on missing required fields. Does NOT default v=AXEL1. +// Ported via Harvard Engine (Semantic pass) module AxelSts; -// __ Types _________________________________________________________________ +// SPDX-License-Identifier: PMPL-1.0-or-later +// AXEL Protocol - DNS TXT Record Parser (Strict) +// +// Parses AXEL DNS TXT record payloads (RDATA only, not full RR lines). +// Fails closed on missing required fields. Does NOT default v=AXEL1. -pub type ParseError = +struct parseError { | MissingVersion - | InvalidVersion(String) + | InvalidVersion(string) | MissingId | EmptyId - | MalformedRecord(String) + | MalformedRecord(string) -pub type AxelRecord = { - version: String, - id: String, +struct axelRecord { { + version: string, + id: string, } -pub type ParseResult = - | ParseOk(AxelRecord) - | ParseErr(ParseError) - -// __ Helpers _______________________________________________________________ +struct parseResult { result -fn str_split_on(s: String, delim: String) -> [String] { - let slen = len(s); - let dlen = len(delim); - let result = []; - let start = 0; - let i = 0; - while i <= slen - dlen { - if string_sub(s, i, dlen) == delim { - result = result ++ [string_sub(s, start, i - start)]; - start = i + dlen; - i = i + dlen; - } else { - i = i + 1; - } - } - result ++ [string_sub(s, start, slen - start)] -} +// String utilities via external bindings +@send external trim: (string) => string = "trim" +@send external split: (string, string) => array = "split" +@send external startsWith: (string, string) => bool = "startsWith" +@get external length: string => int = "length" +@send external slice: (array, int) => array = "slice" +@send external joinArray: (array, string) => string = "join" -fn str_join(parts: [String], sep: String) -> String { - if len(parts) == 0 { return ""; } - let result = parts[0]; - let i = 1; - while i < len(parts) { - result = result ++ sep ++ parts[i]; - i = i + 1; - } - result -} +fn getArrayLength: array => int = %raw(`function(arr) { return arr.length }`) +fn getArrayItem: (array, int) => string = %raw(`function(arr, i) { return arr[i] }`) -fn parse_kv(segment: String) -> Option<(String, String)> { - let trimmed = trim(segment); - let parts = str_split_on(trimmed, "="); - if len(parts) < 2 { +// Parse a single key=value pair from a TXT record segment +fn parseKeyValue = (segment: string): option<(string, string)> => { + fn trimmed = segment->trim + fn parts = trimmed->split("=") + fn len = getArrayLength(parts) + if len < 2 { None } else { - let key = trim(parts[0]); - let vp = []; - let j = 1; - while j < len(parts) { - vp = vp ++ [parts[j]]; - j = j + 1; - } - let value = trim(str_join(vp, "=")); + fn key = getArrayItem(parts, 0)->trim + fn value = parts->slice(1)->joinArray("=")->trim Some((key, value)) } } -// __ Parser ________________________________________________________________ +// Parse a TXT record payload string into an AXEL record +fn parse = (payload: string): parseResult => { + fn trimmed = payload->trim -pub fn parse(payload: String) -> ParseResult { - let trimmed = trim(payload); - if len(trimmed) == 0 { - return ParseErr(MalformedRecord("empty payload")); - } + if trimmed->length == 0 { + Error(MalformedRecord("empty payload")) + } else { + fn segments = trimmed->split(";") + fn version = ref(None) + fn id = ref(None) - let segments = str_split_on(trimmed, ";"); - let version = None; - let id = None; - let i = 0; - while i < len(segments) { - match parse_kv(segments[i]) { - Some(("v", v)) => { version = Some(v); } - Some(("id", d)) => { id = Some(d); } - _ => {} + fn segmentCount = getArrayLength(segments) + for i in 0 to segmentCount - 1 { + fn segment = getArrayItem(segments, i) + switch parseKeyValue(segment) { + | Some(("v", value)) => version := Some(value) + | Some(("id", value)) => id := Some(value) + | _ => () + } } - i = i + 1; - } - match version { - None => ParseErr(MissingVersion()), - Some(v) => { - if v != "AXEL1" { - ParseErr(InvalidVersion(v)) - } else { - match id { - None => ParseErr(MissingId()), - Some(d) => { - let trimmed_id = trim(d); - if len(trimmed_id) == 0 { - ParseErr(EmptyId()) - } else { - ParseOk(AxelRecord { version: v, id: trimmed_id }) - } - } - } - } + switch (version.contents, id.contents) { + | (None, _) => Error(MissingVersion) + | (Some(v), _) if v != "AXEL1" => Error(InvalidVersion(v)) + | (Some(_), None) => Error(MissingId) + | (Some(_), Some(idVal)) if idVal->trim->length == 0 => Error(EmptyId) + | (Some(v), Some(idVal)) => + Ok({ + version: v, + id: idVal->trim, + }) } } } -pub fn error_to_string(err: ParseError) -> String { - match err { - MissingVersion => "Missing required field: v (version)", - InvalidVersion(v) => "Invalid version: '" ++ v ++ "' (expected 'AXEL1')", - MissingId => "Missing required field: id", - EmptyId => "Empty id field (must be non-empty)", - MalformedRecord(m) => "Malformed record: " ++ m, +// Convert parse error to human-readable string +fn errorToString = (err: parseError): string => { + switch err { + | MissingVersion => "Missing required field: v (version)" + | InvalidVersion(v) => "Invalid version: '" ++ v ++ "' (expected 'AXEL1')" + | MissingId => "Missing required field: id" + | EmptyId => "Empty id field (must be non-empty)" + | MalformedRecord(msg) => "Malformed record: " ++ msg } } + diff --git a/axel-protocol/src/ProvenResult.affine b/axel-protocol/src/ProvenResult.affine index 4f7f8434e..9f19968a3 100644 --- a/axel-protocol/src/ProvenResult.affine +++ b/axel-protocol/src/ProvenResult.affine @@ -1,39 +1,50 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell -// -// Result type for proven bindings. AffineScript port of ProvenResult.res. -// Matches the JS { ok: boolean, value?: T, error?: string } pattern. +// Ported via Harvard Engine (Semantic pass) module ProvenResult; -pub type JsResult = { - ok: Bool, - value: Option, - error: Option, +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell +/** + * Result struct for proven bindings + * Matches the JavaScript { ok: boolean, value?: T, error?: string } pattern + */ + +struct t<'value, 'error> = result<'value, 'error> + +// JavaScript interop structs +struct jsResult<'value> = { + ok: bool, + value: option<'value>, + error: option, } -extern fn ok_js(value: v) -> JsResult = "proven/result" "ok"; -extern fn err_js(error: String) -> JsResult = "proven/result" "err"; +@module("proven/result") +external okJs: 'value => jsResult<'value> = "ok" -// Convert a JS result to an AffineScript Result. -pub fn from_js(js: JsResult) -> Result { - if js.ok { - match js.value { - Some(v) => Ok(v), - None => Err("Ok result missing value"), +@module("proven/result") +external errJs: string => jsResult<'never> = "err" + +// Convert JavaScript result to ReScript result +fn fromJs = (jsResult: jsResult<'value>): result<'value, string> => { + if jsResult.ok { + switch jsResult.value { + | Some(v) => Ok(v) + | None => Error("Ok result missing value") } } else { - match js.error { - Some(e) => Err(e), - None => Err("Unknown error"), + switch jsResult.error { + | Some(e) => Error(e) + | None => Error("Unknown error") } } } -// Convert an AffineScript Result to a JS result. -pub fn to_js(r: Result) -> JsResult { - match r { - Ok(value) => JsResult { ok: true, value: Some(value), error: None }, - Err(error) => JsResult { ok: false, value: None, error: Some(error) }, +// Convert ReScript result to JavaScript result +fn toJs = (result: result<'value, string>): jsResult<'value> => { + switch result { + | Ok(value) => {ok: true, value: Some(value), error: None} + | Error(error) => {ok: false, value: None, error: Some(error)} } } + diff --git a/axel-protocol/src/ProvenSafeUrl.affine b/axel-protocol/src/ProvenSafeUrl.affine index b96d22895..2a18fa858 100644 --- a/axel-protocol/src/ProvenSafeUrl.affine +++ b/axel-protocol/src/ProvenSafeUrl.affine @@ -1,76 +1,179 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell -// -// SafeUrl - URL parsing that cannot crash. AffineScript port of -// ProvenSafeUrl.res. Bindings to proven's formally verified URL module. +// Ported via Harvard Engine (Semantic pass) module ProvenSafeUrl; -use ProvenResult; - -pub type ParsedUrl = { - protocol: String, - host: String, - hostname: String, - port: String, - pathname: String, - search: String, - hash: String, - origin: String, - href: String, +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2025 Jonathan D.A. Jewell +/** + * SafeUrl - URL parsing that cannot crash + * + * ReScript bindings to proven's formally verified URL module + */ + +open ProvenResult + +// Parsed URL components +struct parsedUrl { { + protocol: string, + host: string, + hostname: string, + port: string, + pathname: string, + search: string, + hash: string, + origin: string, + href: string, } -// __ JS bindings to proven/safe_url (SafeUrl scope) ______________________ - -extern fn su_parse(url: String, base: Option) -> JsResult = "proven/safe_url" "SafeUrl.parse"; -extern fn su_is_valid(url: String) -> Bool = "proven/safe_url" "SafeUrl.isValid"; -extern fn su_get_query_param(url: String, param: String) -> JsResult> = "proven/safe_url" "SafeUrl.getQueryParam"; -extern fn su_get_query_params(url: String) -> JsResult> = "proven/safe_url" "SafeUrl.getQueryParams"; -extern fn su_set_query_param(url: String, param: String, value: String) -> JsResult = "proven/safe_url" "SafeUrl.setQueryParam"; -extern fn su_remove_query_param(url: String, param: String) -> JsResult = "proven/safe_url" "SafeUrl.removeQueryParam"; -extern fn su_join(base: String, paths: [String]) -> JsResult = "proven/safe_url" "SafeUrl.join"; -extern fn su_get_domain(url: String) -> JsResult = "proven/safe_url" "SafeUrl.getDomain"; -extern fn su_is_https(url: String) -> Bool = "proven/safe_url" "SafeUrl.isHttps"; -extern fn su_encode(s: String) -> String = "proven/safe_url" "SafeUrl.encode"; -extern fn su_decode(s: String) -> JsResult = "proven/safe_url" "SafeUrl.decode"; -extern fn su_normalize(url: String) -> JsResult = "proven/safe_url" "SafeUrl.normalize"; - -// __ Type-safe API _______________________________________________________ - -pub fn parse(url: String, base: Option) -> Result { - from_js(su_parse(url, base)) -} +// JavaScript bindings to proven/safe_url +module SafeUrlJs = { + @module("proven/safe_url") @scope("SafeUrl") + external parse: (string, option) => jsResult = "parse" + + @module("proven/safe_url") @scope("SafeUrl") + external isValid: string => bool = "isValid" + + @module("proven/safe_url") @scope("SafeUrl") + external getQueryParam: (string, string) => jsResult> = "getQueryParam" + + @module("proven/safe_url") @scope("SafeUrl") + external getQueryParams: string => jsResult> = "getQueryParams" + + @module("proven/safe_url") @scope("SafeUrl") + external setQueryParam: (string, string, string) => jsResult = "setQueryParam" + + @module("proven/safe_url") @scope("SafeUrl") + external removeQueryParam: (string, string) => jsResult = "removeQueryParam" + + @module("proven/safe_url") @scope("SafeUrl") + external join: (string, array) => jsResult = "join" + + @module("proven/safe_url") @scope("SafeUrl") + external getDomain: string => jsResult = "getDomain" -pub fn is_valid(url: String) -> Bool { su_is_valid(url) } + @module("proven/safe_url") @scope("SafeUrl") + external isHttps: string => bool = "isHttps" -pub fn get_query_param(url: String, param: String) -> Result, String> { - from_js(su_get_query_param(url, param)) + @module("proven/safe_url") @scope("SafeUrl") + external encode: string => string = "encode" + + @module("proven/safe_url") @scope("SafeUrl") + external decode: string => jsResult = "decode" + + @module("proven/safe_url") @scope("SafeUrl") + external normalize: string => jsResult = "normalize" } -pub fn get_query_params(url: String) -> Result, String> { - from_js(su_get_query_params(url)) +// Type-safe ReScript API +/** + * Parse a URL string safely + * + * @param urlString URL to parse + * @param base Optional base URL + * @returns Result with parsed URL or error message + */ +fn parse = (urlString: string, ~base: option=?) => { + SafeUrlJs.parse(urlString, base)->fromJs } -pub fn set_query_param(url: String, param: String, value: String) -> Result { - from_js(su_set_query_param(url, param, value)) +/** + * Check if string is a valid URL + */ +fn isValid = SafeUrlJs.isValid + +/** + * Get query parameter from URL + * + * @param urlString URL string + * @param param Parameter name + * @returns Result with parameter value (None if not present) or error + */ +fn getQueryParam = (urlString: string, param: string) => { + SafeUrlJs.getQueryParam(urlString, param)->fromJs } -pub fn remove_query_param(url: String, param: String) -> Result { - from_js(su_remove_query_param(url, param)) +/** + * Get all query parameters as dictionary + * + * @param urlString URL string + * @returns Result with dictionary of parameters or error + */ +fn getQueryParams = (urlString: string) => { + SafeUrlJs.getQueryParams(urlString)->fromJs } -pub fn join(base: String, paths: [String]) -> Result { - from_js(su_join(base, paths)) +/** + * Set query parameter on URL + * + * @param urlString URL string + * @param param Parameter name + * @param value Parameter value + * @returns Result with new URL string or error + */ +fn setQueryParam = (urlString: string, param: string, value: string) => { + SafeUrlJs.setQueryParam(urlString, param, value)->fromJs } -pub fn get_domain(url: String) -> Result { - from_js(su_get_domain(url)) +/** + * Remove query parameter from URL + * + * @param urlString URL string + * @param param Parameter name + * @returns Result with new URL string or error + */ +fn removeQueryParam = (urlString: string, param: string) => { + SafeUrlJs.removeQueryParam(urlString, param)->fromJs } -pub fn is_https(url: String) -> Bool { su_is_https(url) } +/** + * Join URL paths safely + * + * @param base Base URL + * @param paths Path segments to join + * @returns Result with joined URL or error + */ +fn join = (base: string, paths: array) => { + SafeUrlJs.join(base, paths)->fromJs +} -pub fn encode(s: String) -> String { su_encode(s) } +/** + * Get the domain from a URL + * + * @param urlString URL string + * @returns Result with domain or error + */ +fn getDomain = (urlString: string) => { + SafeUrlJs.getDomain(urlString)->fromJs +} -pub fn decode(s: String) -> Result { from_js(su_decode(s)) } +/** + * Check if URL uses HTTPS + */ +fn isHttps = SafeUrlJs.isHttps + +/** + * Encode URL component safely + */ +fn encode = SafeUrlJs.encode + +/** + * Decode URL component safely + * + * @param str String to decode + * @returns Result with decoded string or error + */ +fn decode = (str: string) => { + SafeUrlJs.decode(str)->fromJs +} + +/** + * Normalize a URL (lowercase scheme/host, remove default port) + * + * @param urlString URL string + * @returns Result with normalized URL or error + */ +fn normalize = (urlString: string) => { + SafeUrlJs.normalize(urlString)->fromJs +} -pub fn normalize(url: String) -> Result { from_js(su_normalize(url)) } diff --git a/axel-protocol/src/Tea.affine b/axel-protocol/src/Tea.affine index d4d84ce70..22922b0e0 100644 --- a/axel-protocol/src/Tea.affine +++ b/axel-protocol/src/Tea.affine @@ -1,79 +1,75 @@ // SPDX-License-Identifier: MPL-2.0 -// Minimal TEA implementation for STAMP. AffineScript port of Tea.res. -// Compatible with the full rescript-tea architecture. +// Ported via Harvard Engine (Semantic pass) module Tea; -module Cmd { - pub type T = Unit - pub fn none() -> T { Unit } - pub fn msg(_m: msg) -> T { Unit } +// SPDX-License-Identifier: PMPL-1.0-or-later +// Minimal TEA implementation for STAMP +// Compatible with full rescript-tea architecture + +module Cmd = { + struct t<'msg> = unit + fn none = () + fn msg = (_msg: 'msg) => () } -module Sub { - pub type T = Unit - pub fn none() -> T { Unit } +module Sub = { + struct t<'msg> = unit + fn none = () } -module Html { - // A node is represented as its serialised HTML string; an attribute as - // its serialised `name="value"` fragment (faithful to the .res Obj.magic - // string representation). - pub type Node = String - pub type Attribute = String +module Html = { + struct node + struct attribute - extern fn dom_get_by_id(id: String) -> Option = "dom" "getElementById"; - extern fn dom_set_inner_html(el: DomElement, html: String) -> Unit = "dom" "setInnerHTML"; - extern type DomElement; + @val external document: 'a = "document" + @send external getElementById: ('a, string) => Js.Nullable.t = "getElementById" + @set external setInnerHTML: (Dom.element, string) => unit = "innerHTML" - pub fn no_node() -> Node { "" } + fn noNode: node = Obj.magic("") - pub fn text(s: String) -> Node { s } + fn text = (str: string): node => Obj.magic(str) - fn join_nodes(children: [Node]) -> String { - let out = ""; - let i = 0; - while i < len(children) { - out = out ++ children[i]; - i = i + 1; - } - out + fn tag = (tagName: string, _attrs: array, children: array): node => { + // Use raw JavaScript to concatenate children + fn childrenHtml: string = %raw(` + children.map(c => c).join('') + `) + Obj.magic(`<${tagName}>${childrenHtml}`) } - pub fn tag(tag_name: String, _attrs: [Attribute], children: [Node]) -> Node { - "<" ++ tag_name ++ ">" ++ join_nodes(children) ++ "" - } + fn div = (attrs, children) => tag("div", attrs, children) + fn p = (attrs, children) => tag("p", attrs, children) + fn h2 = (attrs, children) => tag("h2", attrs, children) + fn h3 = (attrs, children) => tag("h3", attrs, children) + fn h4 = (attrs, children) => tag("h4", attrs, children) + fn pre = (attrs, children) => tag("pre", attrs, children) + fn code = (attrs, children) => tag("code", attrs, children) + fn button = (attrs, children) => tag("button", attrs, children) + fn section = (attrs, children) => tag("section", attrs, children) - pub fn div(attrs: [Attribute], children: [Node]) -> Node { tag("div", attrs, children) } - pub fn p(attrs: [Attribute], children: [Node]) -> Node { tag("p", attrs, children) } - pub fn h2(attrs: [Attribute], children: [Node]) -> Node { tag("h2", attrs, children) } - pub fn h3(attrs: [Attribute], children: [Node]) -> Node { tag("h3", attrs, children) } - pub fn h4(attrs: [Attribute], children: [Node]) -> Node { tag("h4", attrs, children) } - pub fn pre(attrs: [Attribute], children: [Node]) -> Node { tag("pre", attrs, children) } - pub fn code(attrs: [Attribute], children: [Node]) -> Node { tag("code", attrs, children) } - pub fn button(attrs: [Attribute], children: [Node]) -> Node { tag("button", attrs, children) } - pub fn section(attrs: [Attribute], children: [Node]) -> Node { tag("section", attrs, children) } - - pub fn class(name: String) -> Attribute { "class=\"" ++ name ++ "\"" } - pub fn id(name: String) -> Attribute { "id=\"" ++ name ++ "\"" } - pub fn on_click(_handler: msg) -> Attribute { "" } + fn class' = (name: string): attribute => Obj.magic(`class="${name}"`) + fn id = (name: string): attribute => Obj.magic(`id="${name}"`) + fn onClick = (_handler: 'msg): attribute => Obj.magic("") } -module App { - pub type Program = { - init: fn() -> (model, Cmd.T), - update: fn(model, msg) -> (model, Cmd.T), - view: fn(model) -> Html.Node, - subscriptions: fn(model) -> Sub.T, +module App = { + struct program<'model, 'msg> = { + init: unit => ('model, Cmd.t<'msg>), + update: ('model, 'msg) => ('model, Cmd.t<'msg>), + view: 'model => Html.node, + subscriptions: 'model => Sub.t<'msg>, } - pub fn standard_program(program: Program) -> Effect[IO] Unit { - let (model, _cmd) = (program.init)(); - let html = (program.view)(model); + fn standardProgram = (program: program<'model, 'msg>) => { + fn (model, _cmd) = program.init() + fn html = program.view(model) - match Html.dom_get_by_id("tea-app") { - Some(el) => Html.dom_set_inner_html(el, html), - None => Console.log("TEA mount point #tea-app not found"), + // Mount to DOM + switch Html.document->Html.getElementById("tea-app")->Js.Nullable.toOption { + | Some(el) => el->Html.setInnerHTML(Obj.magic(html)) + | None => Js.log("TEA mount point #tea-app not found") } } } + diff --git a/k9-svc/bindings/deno/src/K9.affine b/k9-svc/bindings/deno/src/K9.affine index c6da435d5..750d39ff1 100644 --- a/k9-svc/bindings/deno/src/K9.affine +++ b/k9-svc/bindings/deno/src/K9.affine @@ -1,36 +1,74 @@ // SPDX-License-Identifier: MPL-2.0 +// Ported via Harvard Engine (Semantic pass) + +module K9; + +// SPDX-License-Identifier: PMPL-1.0-or-later // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) // -// K9 — main module for the K9 (self-validating components) parser library. -// AffineScript port of K9.res. Re-exports core types, parser, renderer. +// K9 — Main module for the K9 (Self-Validating Components) parser library. +// +// Re-exports the core structs, parser, and renderer for convenient access. +// This module serves as the primary entry point for library consumers. +// +// ## Usage +// +// ```rescript +// open K9 +// +// fn result = K9_Parser.parseK9("K9!\n---\nmetadata:\n name: hello\n ...") +// switch result { +// | Ok(component) => Console.log(K9_Renderer.renderK9(component)) +// | Error(err) => Console.error(K9_Types.parseErrorToString(err)) +// } +// ``` -module K9; +// Re-export structs for convenience +struct securityLevel { K9_Types.securityLevel +struct pedigree { K9_Types.pedigree +struct securityPolicy { K9_Types.securityPolicy +struct target { K9_Types.target +struct recipes { K9_Types.recipes +struct validation { K9_Types.validation +struct contractClause { K9_Types.contractClause +struct contract { K9_Types.contract +struct component { K9_Types.component +struct parseError { K9_Types.parseError +struct k9Format { K9_Parser.k9Format + +/// Parse a K9 component specification from a string. +fn parse = K9_Parser.parseK9 + +/// Parse a K9 component specification from a file path. +fn parseFile = K9_Parser.parseK9File + +/// Render a K9 component to the .k9 YAML-like format. +fn render = K9_Renderer.renderK9 + +/// Render a security level to its canonical string. +fn renderSecurityLevel = K9_Renderer.renderSecurityLevel + +/// Detect the format of a K9 file (YAML or Nickel). +fn detectFormat = K9_Parser.detectFormat + +/// Create a minimal component. +fn makeComponent = K9_Types.makeComponent + +/// Create a pedigree with minimum required fields. +fn makePedigree = K9_Types.makePedigree + +/// Create a default security policy for a given level. +fn defaultSecurityPolicy = K9_Types.defaultSecurityPolicy + +/// Create an empty recipes collection. +fn emptyRecipes = K9_Types.emptyRecipes + +/// Parse a security level from a string. +fn securityLevelFromString = K9_Types.securityLevelFromString + +/// Convert a security level to its canonical string. +fn securityLevelToString = K9_Types.securityLevelToString + +/// Format a parse error as a diagnostic string. +fn parseErrorToString = K9_Types.parseErrorToString -use K9_Types; -use K9_Parser; -use K9_Renderer; - -pub type SecurityLevel = K9_Types.SecurityLevel; -pub type Pedigree = K9_Types.Pedigree; -pub type SecurityPolicy = K9_Types.SecurityPolicy; -pub type Target = K9_Types.Target; -pub type Recipes = K9_Types.Recipes; -pub type Validation = K9_Types.Validation; -pub type ContractClause = K9_Types.ContractClause; -pub type Contract = K9_Types.Contract; -pub type Component = K9_Types.Component; -pub type ParseError = K9_Types.ParseError; -pub type K9Format = K9_Parser.K9Format; - -pub let parse = K9_Parser.parse_k9; -pub let parse_file = K9_Parser.parse_k9_file; -pub let render = K9_Renderer.render_k9; -pub let render_security_level = K9_Renderer.render_security_level; -pub let detect_format = K9_Parser.detect_format; -pub let make_component = K9_Types.make_component; -pub let make_pedigree = K9_Types.make_pedigree; -pub let default_security_policy = K9_Types.default_security_policy; -pub let empty_recipes = K9_Types.empty_recipes; -pub let security_level_from_string = K9_Types.security_level_from_string; -pub let security_level_to_string = K9_Types.security_level_to_string; -pub let parse_error_to_string = K9_Types.parse_error_to_string; diff --git a/k9-svc/bindings/deno/src/K9_Parser.affine b/k9-svc/bindings/deno/src/K9_Parser.affine index 153acdd6e..0d5b17d2b 100644 --- a/k9-svc/bindings/deno/src/K9_Parser.affine +++ b/k9-svc/bindings/deno/src/K9_Parser.affine @@ -1,264 +1,378 @@ // SPDX-License-Identifier: MPL-2.0 +// Ported via Harvard Engine (Semantic pass) + +module K9_Parser; + +// SPDX-License-Identifier: PMPL-1.0-or-later // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) // -// K9_Parser — parser for K9 self-validating component specifications. -// AffineScript port of K9_Parser.res. +// K9_Parser — Parser for K9 self-validating component specifications. +// +// Parses the YAML-like .k9 format into the structd AST defined in K9_Types. +// The parser is line-oriented and extracts: +// - Magic number (K9!) +// - Pedigree metadata (name, version, description, author, license) +// - Security level (Kennel/Yard/Hunt) with permission flags +// - Target platform constraints +// - Recipes and validation blocks +// - Tags -module K9_Parser; +open K9_Types -use K9_Types; - -extern fn str_trim(s: String) -> String = "string" "trim"; -extern fn str_lower(s: String) -> String = "string" "toLowerCase"; -extern fn str_index_of(s: String, needle: String) -> Int = "string" "indexOf"; -extern fn str_slice(s: String, start: Int, end: Int) -> String = "string" "slice"; -extern fn str_slice_to_end(s: String, start: Int) -> String = "string" "sliceToEnd"; -extern fn str_starts_with(s: String, p: String) -> Bool = "string" "startsWith"; -extern fn str_split(s: String, sep: String) -> [String] = "string" "split"; -extern fn read_file_sync(path: String, enc: String) -> String = "node:fs" "readFileSync"; - -pub fn parse_key_value(line: String) -> Option<(String, String)> { - let trimmed = str_trim(line); - let colon_idx = str_index_of(trimmed, ":"); - if colon_idx >= 0 { - let key = str_trim(str_slice(trimmed, 0, colon_idx)); - let value = str_trim(str_slice_to_end(trimmed, colon_idx + 1)); +// --------------------------------------------------------------------------- +// Parsing helpers +// --------------------------------------------------------------------------- + +/// Extract a key-value pair from a " key: value" line. +/// Returns None if the line does not match the expected format. +fn parseKeyValue = (line: string): option<(string, string)> => { + fn trimmed = line->String.trim + fn colonIdx = trimmed->String.indexOf(":") + if colonIdx >= 0 { + fn key = trimmed->String.slice(~start=0, ~end=colonIdx)->String.trim + fn value = trimmed->String.sliceToEnd(~start=colonIdx + 1)->String.trim Some((key, value)) } else { None } } -pub fn parse_bool(s: String) -> Bool { - str_lower(str_trim(s)) == "true" +/// Parse a boolean from a string ("true"/"false"). +fn parseBool = (s: string): bool => { + s->String.trim->String.toLowerCase == "true" } -pub type ParserState = { - mut line_index: Int, - lines: [String], +/// Internal state for the line-oriented K9 parser. +struct parserState { { + mutable lineIndex: int, + lines: array, } -pub fn skip_blanks_and_separators(state: ParserState) -> Unit { - let done = false; - while state.line_index < len(state.lines) && !done { - let line = str_trim(state.lines[state.line_index]); - if len(line) == 0 || line == "---" { - state.line_index = state.line_index + 1; +/// Advance past blank lines and separator lines (---). +fn skipBlanksAndSeparators = (state: parserState): unit => { + fn done = ref(false) + while state.lineIndex < state.lines->Array.length && !done.contents { + fn line = state.lines->Array.getUnsafe(state.lineIndex)->String.trim + if line->String.length == 0 || line == "---" { + state.lineIndex = state.lineIndex + 1 } else { - done = true; + done := true } } } -pub fn is_section_header(line: String, section: String) -> Bool { - str_trim(line) == section ++ ":" +/// Check if the current line matches a section header (e.g., "metadata:"). +fn isSectionHeader = (line: string, section: string): bool => { + line->String.trim == section ++ ":" } -pub fn parse_pedigree_section(state: ParserState) -> K9_Types.Pedigree { - state.line_index = state.line_index + 1; - let name = ""; let version = ""; let description = ""; - let author = None; let license = None; let done = false; - while state.line_index < len(state.lines) && !done { - let line = state.lines[state.line_index]; - let trimmed = str_trim(line); - if str_starts_with(line, " ") && len(trimmed) > 0 { - match parse_key_value(trimmed) { - Some(("name", v)) => { name = v; } - Some(("version", v)) => { version = v; } - Some(("description", v)) => { description = v; } - Some(("author", v)) => { author = Some(v); } - Some(("license", v)) => { license = Some(v); } - _ => {} +// --------------------------------------------------------------------------- +// Section parsers +// --------------------------------------------------------------------------- + +/// Parse the metadata/pedigree section. +/// Reads indented key-value pairs until a non-indented line or new section. +fn parsePedigreeSection = (state: parserState): pedigree => { + // Skip the "metadata:" header line + state.lineIndex = state.lineIndex + 1 + + fn name = ref("") + fn version = ref("") + fn description = ref("") + fn author = ref(None) + fn license = ref(None) + fn done = ref(false) + + while state.lineIndex < state.lines->Array.length && !done.contents { + fn line = state.lines->Array.getUnsafe(state.lineIndex) + fn trimmed = line->String.trim + // Indented lines belong to this section + if line->String.startsWith(" ") && trimmed->String.length > 0 { + switch parseKeyValue(trimmed) { + | Some(("name", v)) => name := v + | Some(("version", v)) => version := v + | Some(("description", v)) => description := v + | Some(("author", v)) => author := Some(v) + | Some(("license", v)) => license := Some(v) + | _ => () // Ignore unknown fields } - state.line_index = state.line_index + 1; + state.lineIndex = state.lineIndex + 1 } else { - done = true; + done := true } } - K9_Types.Pedigree { name: name, version: version, description: description, author: author, license: license } + + { + name: name.contents, + version: version.contents, + description: description.contents, + author: author.contents, + license: license.contents, + } } -pub fn parse_security_section(state: ParserState) -> K9_Types.SecurityPolicy { - state.line_index = state.line_index + 1; - let level = K9_Types.Kennel; - let allow_network = false; let allow_fs_write = false; let allow_subprocess = false; - let done = false; - while state.line_index < len(state.lines) && !done { - let line = state.lines[state.line_index]; - let trimmed = str_trim(line); - if str_starts_with(line, " ") && len(trimmed) > 0 { - match parse_key_value(trimmed) { - Some(("trust_level", v)) => { - match K9_Types.security_level_from_string(v) { Some(lvl) => { level = lvl; } None => {} } +/// Parse the security section. +fn parseSecuritySection = (state: parserState): securityPolicy => { + // Skip the "security:" header line + state.lineIndex = state.lineIndex + 1 + + fn level = ref(Kennel) + fn allowNetwork = ref(false) + fn allowFsWrite = ref(false) + fn allowSubprocess = ref(false) + fn done = ref(false) + + while state.lineIndex < state.lines->Array.length && !done.contents { + fn line = state.lines->Array.getUnsafe(state.lineIndex) + fn trimmed = line->String.trim + if line->String.startsWith(" ") && trimmed->String.length > 0 { + switch parseKeyValue(trimmed) { + | Some(("trust_level", v)) => + switch securityLevelFromString(v) { + | Some(lvl) => level := lvl + | None => () // Keep default } - Some(("allow_network", v)) => { allow_network = parse_bool(v); } - Some(("allow_filesystem_write", v)) => { allow_fs_write = parse_bool(v); } - Some(("allow_subprocess", v)) => { allow_subprocess = parse_bool(v); } - _ => {} + | Some(("allow_network", v)) => allowNetwork := parseBool(v) + | Some(("allow_filesystem_write", v)) => allowFsWrite := parseBool(v) + | Some(("allow_subprocess", v)) => allowSubprocess := parseBool(v) + | _ => () } - state.line_index = state.line_index + 1; + state.lineIndex = state.lineIndex + 1 } else { - done = true; + done := true } } - K9_Types.SecurityPolicy { level: level, allow_network: allow_network, allow_fs_write: allow_fs_write, allow_subprocess: allow_subprocess } + + { + level: level.contents, + allowNetwork: allowNetwork.contents, + allowFsWrite: allowFsWrite.contents, + allowSubprocess: allowSubprocess.contents, + } } -pub fn parse_target_section(state: ParserState) -> K9_Types.Target { - state.line_index = state.line_index + 1; - let os = None; let is_edge = false; let requires_podman = false; let memory = None; - let done = false; - while state.line_index < len(state.lines) && !done { - let line = state.lines[state.line_index]; - let trimmed = str_trim(line); - if str_starts_with(line, " ") && len(trimmed) > 0 { - match parse_key_value(trimmed) { - Some(("os", v)) => { os = Some(v); } - Some(("is_edge", v)) => { is_edge = parse_bool(v); } - Some(("requires_podman", v)) => { requires_podman = parse_bool(v); } - Some(("memory", v)) => { memory = Some(v); } - _ => {} +/// Parse the target section. +fn parseTargetSection = (state: parserState): target => { + // Skip the "target:" header line + state.lineIndex = state.lineIndex + 1 + + fn os = ref(None) + fn isEdge = ref(false) + fn requiresPodman = ref(false) + fn memory = ref(None) + fn done = ref(false) + + while state.lineIndex < state.lines->Array.length && !done.contents { + fn line = state.lines->Array.getUnsafe(state.lineIndex) + fn trimmed = line->String.trim + if line->String.startsWith(" ") && trimmed->String.length > 0 { + switch parseKeyValue(trimmed) { + | Some(("os", v)) => os := Some(v) + | Some(("is_edge", v)) => isEdge := parseBool(v) + | Some(("requires_podman", v)) => requiresPodman := parseBool(v) + | Some(("memory", v)) => memory := Some(v) + | _ => () } - state.line_index = state.line_index + 1; + state.lineIndex = state.lineIndex + 1 } else { - done = true; + done := true } } - K9_Types.Target { os: os, is_edge: is_edge, requires_podman: requires_podman, memory: memory } + + { + os: os.contents, + isEdge: isEdge.contents, + requiresPodman: requiresPodman.contents, + memory: memory.contents, + } } -pub fn parse_recipes_section(state: ParserState) -> K9_Types.Recipes { - state.line_index = state.line_index + 1; - let install = None; let validate = None; let deploy = None; let migrate = None; - let custom = []; let done = false; - while state.line_index < len(state.lines) && !done { - let line = state.lines[state.line_index]; - let trimmed = str_trim(line); - if str_starts_with(line, " ") && len(trimmed) > 0 { - match parse_key_value(trimmed) { - Some(("install", v)) => { install = Some(v); } - Some(("validate", v)) => { validate = Some(v); } - Some(("deploy", v)) => { deploy = Some(v); } - Some(("migrate", v)) => { migrate = Some(v); } - Some((k, v)) => { custom = custom ++ [(k, v)]; } - None => {} +/// Parse the recipes section. +fn parseRecipesSection = (state: parserState): recipes => { + // Skip the "recipes:" header line + state.lineIndex = state.lineIndex + 1 + + fn install = ref(None) + fn validate = ref(None) + fn deploy = ref(None) + fn migrate = ref(None) + fn custom = [] + fn done = ref(false) + + while state.lineIndex < state.lines->Array.length && !done.contents { + fn line = state.lines->Array.getUnsafe(state.lineIndex) + fn trimmed = line->String.trim + if line->String.startsWith(" ") && trimmed->String.length > 0 { + switch parseKeyValue(trimmed) { + | Some(("install", v)) => install := Some(v) + | Some(("validate", v)) => validate := Some(v) + | Some(("deploy", v)) => deploy := Some(v) + | Some(("migrate", v)) => migrate := Some(v) + | Some((k, v)) => custom->Array.push((k, v))->ignore + | None => () } - state.line_index = state.line_index + 1; + state.lineIndex = state.lineIndex + 1 } else { - done = true; + done := true } } - K9_Types.Recipes { install: install, validate: validate, deploy: deploy, migrate: migrate, custom: custom } + + { + install: install.contents, + validate: validate.contents, + deploy: deploy.contents, + migrate: migrate.contents, + custom, + } } -pub fn parse_validation_section(state: ParserState) -> K9_Types.Validation { - state.line_index = state.line_index + 1; - let checksum = ""; let pedigree_version = ""; let hunt_authorized = false; - let done = false; - while state.line_index < len(state.lines) && !done { - let line = state.lines[state.line_index]; - let trimmed = str_trim(line); - if str_starts_with(line, " ") && len(trimmed) > 0 { - match parse_key_value(trimmed) { - Some(("checksum", v)) => { checksum = v; } - Some(("pedigree_version", v)) => { pedigree_version = v; } - Some(("hunt_authorized", v)) => { hunt_authorized = parse_bool(v); } - _ => {} +/// Parse the validation section. +fn parseValidationSection = (state: parserState): validation => { + // Skip the "validation:" header line + state.lineIndex = state.lineIndex + 1 + + fn checksum = ref("") + fn pedigreeVersion = ref("") + fn huntAuthorized = ref(false) + fn done = ref(false) + + while state.lineIndex < state.lines->Array.length && !done.contents { + fn line = state.lines->Array.getUnsafe(state.lineIndex) + fn trimmed = line->String.trim + if line->String.startsWith(" ") && trimmed->String.length > 0 { + switch parseKeyValue(trimmed) { + | Some(("checksum", v)) => checksum := v + | Some(("pedigree_version", v)) => pedigreeVersion := v + | Some(("hunt_authorized", v)) => huntAuthorized := parseBool(v) + | _ => () } - state.line_index = state.line_index + 1; + state.lineIndex = state.lineIndex + 1 } else { - done = true; + done := true } } - K9_Types.Validation { checksum: checksum, pedigree_version: pedigree_version, hunt_authorized: hunt_authorized } + + { + checksum: checksum.contents, + pedigreeVersion: pedigreeVersion.contents, + huntAuthorized: huntAuthorized.contents, + } } -pub fn parse_tags_section(state: ParserState) -> [String] { - state.line_index = state.line_index + 1; - let tags = []; let done = false; - while state.line_index < len(state.lines) && !done { - let line = state.lines[state.line_index]; - let trimmed = str_trim(line); - if str_starts_with(trimmed, "- ") { - tags = tags ++ [str_trim(str_slice_to_end(trimmed, 2))]; - state.line_index = state.line_index + 1; - } else if str_starts_with(line, " ") && len(trimmed) > 0 { - tags = tags ++ [trimmed]; - state.line_index = state.line_index + 1; +/// Parse the tags section. +fn parseTagsSection = (state: parserState): array => { + // Skip the "tags:" header line + state.lineIndex = state.lineIndex + 1 + + fn tags = [] + fn done = ref(false) + + while state.lineIndex < state.lines->Array.length && !done.contents { + fn line = state.lines->Array.getUnsafe(state.lineIndex) + fn trimmed = line->String.trim + if trimmed->String.startsWith("- ") { + fn tag = trimmed->String.sliceToEnd(~start=2)->String.trim + tags->Array.push(tag)->ignore + state.lineIndex = state.lineIndex + 1 + } else if line->String.startsWith(" ") && trimmed->String.length > 0 { + // Also handle indented non-dash entries + tags->Array.push(trimmed)->ignore + state.lineIndex = state.lineIndex + 1 } else { - done = true; + done := true } } + tags } -pub fn parse_k9(input: String) -> Result { - let trimmed = str_trim(input); - if len(trimmed) == 0 { - Err(K9_Types.EmptyDocument) +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Parse a K9 component specification from a string. +/// +/// The input must start with the K9! magic number. +/// Returns either a parseError or the parsed component. +/// +/// ### Example +/// ``` +/// fn result = parseK9("K9!\n---\nmetadata:\n name: hello-k9\n ...") +/// ``` +fn parseK9 = (input: string): result => { + fn trimmed = input->String.trim + if trimmed->String.length == 0 { + Error(EmptyDocument) } else { - let lines = str_split(input, "\n"); - let state = ParserState { line_index: 0, lines: lines }; + fn lines = input->String.split("\n") + fn state: parserState = {lineIndex: 0, lines} - skip_blanks_and_separators(state); - if state.line_index >= len(lines) { - Err(K9_Types.EmptyDocument) + // Check for K9! magic number + skipBlanksAndSeparators(state) + if state.lineIndex >= lines->Array.length { + Error(EmptyDocument) } else { - let first_line = str_trim(lines[state.line_index]); - if first_line != "K9!" { - Err(K9_Types.MissingMagicNumber) + fn firstLine = lines->Array.getUnsafe(state.lineIndex)->String.trim + if firstLine != "K9!" { + Error(MissingMagicNumber) } else { - state.line_index = state.line_index + 1; - skip_blanks_and_separators(state); - - let pedigree = K9_Types.make_pedigree("", "", ""); - let security = K9_Types.default_security_policy(K9_Types.Kennel); - let target = None; - let recipes = None; - let validation = None; - let tags = []; - let content = []; - - while state.line_index < len(lines) { - let line = str_trim(lines[state.line_index]); - if len(line) == 0 || line == "---" { - state.line_index = state.line_index + 1; - } else if is_section_header(line, "metadata") { - pedigree = parse_pedigree_section(state); - } else if is_section_header(line, "security") { - security = parse_security_section(state); - } else if is_section_header(line, "target") { - target = Some(parse_target_section(state)); - } else if is_section_header(line, "recipes") { - recipes = Some(parse_recipes_section(state)); - } else if is_section_header(line, "validation") { - validation = Some(parse_validation_section(state)); - } else if is_section_header(line, "tags") { - tags = parse_tags_section(state); + state.lineIndex = state.lineIndex + 1 + skipBlanksAndSeparators(state) + + // Parse sections in order + fn pedigreeRef = ref(makePedigree(~name="", ~version="", ~description="")) + fn securityRef = ref(defaultSecurityPolicy(Kennel)) + fn targetRef = ref(None) + fn recipesRef = ref(None) + fn validationRef = ref(None) + fn tagsRef = ref([]) + fn contentRef = ref([]) + + while state.lineIndex < lines->Array.length { + fn line = lines->Array.getUnsafe(state.lineIndex)->String.trim + + if line->String.length == 0 || line == "---" { + state.lineIndex = state.lineIndex + 1 + } else if isSectionHeader(line, "metadata") { + pedigreeRef := parsePedigreeSection(state) + } else if isSectionHeader(line, "security") { + securityRef := parseSecuritySection(state) + } else if isSectionHeader(line, "target") { + targetRef := Some(parseTargetSection(state)) + } else if isSectionHeader(line, "recipes") { + recipesRef := Some(parseRecipesSection(state)) + } else if isSectionHeader(line, "validation") { + validationRef := Some(parseValidationSection(state)) + } else if isSectionHeader(line, "tags") { + tagsRef := parseTagsSection(state) } else { - match parse_key_value(line) { - Some((k, v)) => { content = content ++ [(k, v)]; } - None => {} + // Unknown key-value pair at root level — store as content + switch parseKeyValue(line) { + | Some((k, v)) => + contentRef.contents->Array.push((k, v))->ignore + | None => () } - state.line_index = state.line_index + 1; + state.lineIndex = state.lineIndex + 1 } } - if len(pedigree.name) == 0 { - Err(K9_Types.MissingPedigree("name")) - } else if len(pedigree.version) == 0 { - Err(K9_Types.MissingPedigree("version")) - } else if len(pedigree.description) == 0 { - Err(K9_Types.MissingPedigree("description")) + // Validate required pedigree fields + fn ped = pedigreeRef.contents + if ped.name->String.length == 0 { + Error(MissingPedigree("name")) + } else if ped.version->String.length == 0 { + Error(MissingPedigree("version")) + } else if ped.description->String.length == 0 { + Error(MissingPedigree("description")) } else { - Ok(K9_Types.Component { - pedigree: pedigree, - security: security, - target: target, - recipes: recipes, - validation: validation, - content: content, - tags: tags, + Ok({ + pedigree: ped, + security: securityRef.contents, + target: targetRef.contents, + recipes: recipesRef.contents, + validation: validationRef.contents, + content: contentRef.contents, + tags: tagsRef.contents, }) } } @@ -266,12 +380,32 @@ pub fn parse_k9(input: String) -> Result Result { - parse_k9(read_file_sync(path, "utf-8")) +/// Parse a K9 component specification from a file path. +/// Uses Node.js fs.readFileSync for Deno compatibility. +@module("node:fs") +external readFileSync: (string, string) => string = "readFileSync" + +fn parseK9File = (path: string): result => { + fn content = readFileSync(path, "utf-8") + parseK9(content) } -pub type K9Format = | K9Yaml | K9Nickel +// --------------------------------------------------------------------------- +// Format detection +// --------------------------------------------------------------------------- -pub fn detect_format(input: String) -> K9Format { - if str_starts_with(str_trim(input), "K9!") { K9Yaml } else { K9Nickel } +/// K9 file format variants. +struct k9Format { + | K9Yaml + | K9Nickel + +/// Detect whether a K9 file is YAML-like (.k9) or Nickel (.k9.ncl). +fn detectFormat = (input: string): k9Format => { + fn trimmed = input->String.trim + if trimmed->String.startsWith("K9!") { + K9Yaml + } else { + K9Nickel + } } + diff --git a/k9-svc/bindings/deno/src/K9_Renderer.affine b/k9-svc/bindings/deno/src/K9_Renderer.affine index acfc81a23..79852ee1d 100644 --- a/k9-svc/bindings/deno/src/K9_Renderer.affine +++ b/k9-svc/bindings/deno/src/K9_Renderer.affine @@ -1,108 +1,171 @@ // SPDX-License-Identifier: MPL-2.0 +// Ported via Harvard Engine (Semantic pass) + +module K9_Renderer; + +// SPDX-License-Identifier: PMPL-1.0-or-later // Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) // -// K9_Renderer — render K9 AST back to K9 surface syntax. -// AffineScript port of K9_Renderer.res. +// K9_Renderer — Render K9 AST back to K9 surface syntax. +// +// Converts the structd AST from K9_Types into the YAML-like .k9 format, +// including the K9! magic number, pedigree, security, target, recipes, +// validation, and tags sections. -module K9_Renderer; +open K9_Types -use K9_Types; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- -pub fn render_bool(b: Bool) -> String { if b { "true" } else { "false" } } +/// Render a boolean as lowercase text ("true"/"false"). +fn renderBool = (b: bool): string => { + if b { + "true" + } else { + "false" + } +} -pub fn render_optional(key: String, value: Option) -> [String] { - match value { Some(v) => [key ++ ": " ++ v], None => [] } +/// Render an optional field. Returns an array containing one line if +/// the value is Some, or an empty array if None. +fn renderOptional = (key: string, value: option): array => { + switch value { + | Some(v) => [key ++ ": " ++ v] + | None => [] + } } -pub fn render_pedigree_section(ped: K9_Types.Pedigree) -> [String] { - ["metadata:", - " name: " ++ ped.name, - " version: " ++ ped.version, - " description: " ++ ped.description] - ++ render_optional(" author", ped.author) - ++ render_optional(" license", ped.license) +// --------------------------------------------------------------------------- +// Section renderers +// --------------------------------------------------------------------------- + +/// Render the pedigree/metadata section. +fn renderPedigreeSection = (ped: pedigree): array => { + Array.concat( + [ + "metadata:", + " name: " ++ ped.name, + " version: " ++ ped.version, + " description: " ++ ped.description, + ], + Array.concat( + renderOptional(" author", ped.author), + renderOptional(" license", ped.license), + ), + ) } -pub fn render_security_section(sec: K9_Types.SecurityPolicy) -> [String] { - ["", - "security:", - " trust_level: " ++ K9_Types.security_level_to_string(sec.level), - " allow_network: " ++ render_bool(sec.allow_network), - " allow_filesystem_write: " ++ render_bool(sec.allow_fs_write), - " allow_subprocess: " ++ render_bool(sec.allow_subprocess)] +/// Render the security section. +fn renderSecuritySection = (sec: securityPolicy): array => { + [ + "", + "security:", + " trust_level: " ++ securityLevelToString(sec.level), + " allow_network: " ++ renderBool(sec.allowNetwork), + " allow_filesystem_write: " ++ renderBool(sec.allowFsWrite), + " allow_subprocess: " ++ renderBool(sec.allowSubprocess), + ] } -pub fn render_target_section(tgt: Option) -> [String] { - match tgt { - None => [], - Some(t) => - ["", "target:"] ++ render_optional(" os", t.os) - ++ [" is_edge: " ++ render_bool(t.is_edge), - " requires_podman: " ++ render_bool(t.requires_podman)] - ++ render_optional(" memory", t.memory), +/// Render the target section if present. +fn renderTargetSection = (tgt: option): array => { + switch tgt { + | None => [] + | Some(t) => + Array.concat( + Array.concat(["", "target:"], renderOptional(" os", t.os)), + Array.concat( + [ + " is_edge: " ++ renderBool(t.isEdge), + " requires_podman: " ++ renderBool(t.requiresPodman), + ], + renderOptional(" memory", t.memory), + ), + ) } } -pub fn render_recipes_section(rec_: Option) -> [String] { - match rec_ { - None => [], - Some(r) => { - let standard = render_optional(" install", r.install) - ++ render_optional(" validate", r.validate) - ++ render_optional(" deploy", r.deploy) - ++ render_optional(" migrate", r.migrate); - let custom_lines = []; - let i = 0; - while i < len(r.custom) { - let (k, v) = r.custom[i]; - custom_lines = custom_lines ++ [" " ++ k ++ ": " ++ v]; - i = i + 1; - } - ["", "recipes:"] ++ standard ++ custom_lines - } +/// Render the recipes section if present. +fn renderRecipesSection = (rec_: option): array => { + switch rec_ { + | None => [] + | Some(r) => + fn lines = ["", "recipes:"] + fn standard = Array.concat( + Array.concat( + renderOptional(" install", r.install), + renderOptional(" validate", r.validate), + ), + Array.concat( + renderOptional(" deploy", r.deploy), + renderOptional(" migrate", r.migrate), + ), + ) + fn customLines = r.custom->Array.map(((k, v)) => " " ++ k ++ ": " ++ v) + Array.concat(lines, Array.concat(standard, customLines)) } } -pub fn render_validation_section(val_: Option) -> [String] { - match val_ { - None => [], - Some(v) => ["", - "validation:", - " checksum: " ++ v.checksum, - " pedigree_version: " ++ v.pedigree_version, - " hunt_authorized: " ++ render_bool(v.hunt_authorized)], +/// Render the validation section if present. +fn renderValidationSection = (val_: option): array => { + switch val_ { + | None => [] + | Some(v) => [ + "", + "validation:", + " checksum: " ++ v.checksum, + " pedigree_version: " ++ v.pedigreeVersion, + " hunt_authorized: " ++ renderBool(v.huntAuthorized), + ] } } -pub fn render_tags_section(tags: [String]) -> [String] { - if len(tags) == 0 { +/// Render the tags section if non-empty. +fn renderTagsSection = (tags: array): array => { + if tags->Array.length == 0 { [] } else { - let items = []; - let i = 0; - while i < len(tags) { items = items ++ [" - " ++ tags[i]]; i = i + 1; } - ["", "tags:"] ++ items + fn header = ["", "tags:"] + fn items = tags->Array.map(t => " - " ++ t) + Array.concat(header, items) } } -pub fn render_k9(c: K9_Types.Component) -> String { - let lines = ["K9!", "---"] - ++ render_pedigree_section(c.pedigree) - ++ render_security_section(c.security) - ++ render_target_section(c.target) - ++ render_recipes_section(c.recipes) - ++ render_validation_section(c.validation) - ++ render_tags_section(c.tags); - - let out = ""; - let i = 0; - while i < len(lines) { - out = if i == 0 { lines[i] } else { out ++ "\n" ++ lines[i] }; - i = i + 1; - } - out ++ "\n" -} +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- -pub fn render_security_level(level: K9_Types.SecurityLevel) -> String { - K9_Types.security_level_to_string(level) +/// Render a complete K9 component to the .k9 YAML-like format. +/// +/// ### Example +/// ``` +/// fn text = renderK9(component) +/// // "K9!\n---\nmetadata:\n name: hello-k9\n ..." +/// ``` +fn renderK9 = (c: component): string => { + fn lines = Array.concat( + ["K9!", "---"], + Array.concat( + renderPedigreeSection(c.pedigree), + Array.concat( + renderSecuritySection(c.security), + Array.concat( + renderTargetSection(c.target), + Array.concat( + renderRecipesSection(c.recipes), + Array.concat(renderValidationSection(c.validation), renderTagsSection(c.tags)), + ), + ), + ), + ), + ) + + // Filter out empty strings that would create unwanted blank lines at the end + fn filtered = lines->Array.filter(l => l->String.length > 0 || l == "") + filtered->Array.join("\n") ++ "\n" } + +/// Render a security level to its canonical text representation. +fn renderSecurityLevel = securityLevelToString + diff --git a/k9-svc/bindings/deno/src/K9_Types.affine b/k9-svc/bindings/deno/src/K9_Types.affine index ec86f1233..86767d782 100644 --- a/k9-svc/bindings/deno/src/K9_Types.affine +++ b/k9-svc/bindings/deno/src/K9_Types.affine @@ -1,135 +1,224 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) -// -// K9_Types — core data types for K9 (self-validating components). -// AffineScript port of K9_Types.res. +// Ported via Harvard Engine (Semantic pass) module K9_Types; -extern fn str_trim(s: String) -> String = "string" "trim"; -extern fn str_lower(s: String) -> String = "string" "toLowerCase"; -extern fn str_starts_with(s: String, p: String) -> Bool = "string" "startsWith"; -extern fn str_slice_to_end(s: String, start: Int) -> String = "string" "sliceToEnd"; - -pub type SecurityLevel = | Kennel | Yard | Hunt - -pub fn security_level_from_string(s: String) -> Option { - let normalized = str_lower(str_trim(s)); - let cleaned = if str_starts_with(normalized, "'") { - str_slice_to_end(normalized, 1) +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// K9_Types — Core data structs for K9 (Self-Validating Components). +// +// Defines the abstract syntax tree for K9 component specifications, +// including pedigree metadata, security levels (Kennel/Yard/Hunt), +// target platform constraints, lifecycle recipes, validation blocks, +// and contract clauses. + +// --------------------------------------------------------------------------- +// Security levels +// --------------------------------------------------------------------------- + +/// K9 security levels forming a trust hierarchy. +/// +/// - Kennel: Pure data, no execution, safe anywhere. +/// - Yard: Controlled execution, limited permissions. +/// - Hunt: Full execution with explicit authorisation required. +struct securityLevel { + | Kennel + | Yard + | Hunt + +/// Parse a security level from its canonical string representation. +/// Recognised values (case-insensitive): "kennel", "yard", "hunt". +/// Also accepts tick-prefixed forms: "'Kennel", "'Yard", "'Hunt". +fn securityLevelFromString = (s: string): option => { + fn normalized = s->String.trim->String.toLowerCase + // Strip leading tick if present (e.g., "'kennel" -> "kennel") + fn cleaned = if normalized->String.startsWith("'") { + normalized->String.sliceToEnd(~start=1) } else { normalized - }; - match cleaned { - "kennel" => Some(Kennel), - "yard" => Some(Yard), - "hunt" => Some(Hunt), - _ => None, + } + switch cleaned { + | "kennel" => Some(Kennel) + | "yard" => Some(Yard) + | "hunt" => Some(Hunt) + | _ => None } } -pub fn security_level_to_string(level: SecurityLevel) -> String { - match level { Kennel => "'Kennel", Yard => "'Yard", Hunt => "'Hunt" } +/// Return the canonical string representation of a security level. +/// Uses the tick-prefixed form matching the K9 spec (e.g., "'Kennel"). +fn securityLevelToString = (level: securityLevel): string => { + switch level { + | Kennel => "'Kennel" + | Yard => "'Yard" + | Hunt => "'Hunt" + } } -pub type Pedigree = { - name: String, - version: String, - description: String, - author: Option, - license: Option, +// --------------------------------------------------------------------------- +// Pedigree metadata +// --------------------------------------------------------------------------- + +/// Pedigree: identity and provenance metadata for a K9 component. +struct pedigree { { + name: string, + version: string, + description: string, + author: option, + license: option, } -pub fn make_pedigree(name: String, version: String, description: String) -> Pedigree { - Pedigree { name: name, version: version, description: description, author: None, license: None } +/// Create a pedigree with the minimum required fields. +fn makePedigree = ( + ~name: string, + ~version: string, + ~description: string, +): pedigree => { + name, + version, + description, + author: None, + license: None, } -pub type SecurityPolicy = { - level: SecurityLevel, - allow_network: Bool, - allow_fs_write: Bool, - allow_subprocess: Bool, +// --------------------------------------------------------------------------- +// Security policy +// --------------------------------------------------------------------------- + +/// Security policy combining the level with specific permission flags. +struct securityPolicy { { + level: securityLevel, + allowNetwork: bool, + allowFsWrite: bool, + allowSubprocess: bool, } -pub fn default_security_policy(level: SecurityLevel) -> SecurityPolicy { - match level { - Kennel => SecurityPolicy { level: level, allow_network: false, allow_fs_write: false, allow_subprocess: false }, - Yard => SecurityPolicy { level: level, allow_network: true, allow_fs_write: false, allow_subprocess: false }, - Hunt => SecurityPolicy { level: level, allow_network: true, allow_fs_write: true, allow_subprocess: true }, +/// Create a default security policy for the given level. +/// Kennel: all permissions denied. +/// Yard: network allowed, filesystem write and subprocess denied. +/// Hunt: all permissions allowed. +fn defaultSecurityPolicy = (level: securityLevel): securityPolicy => { + switch level { + | Kennel => {level, allowNetwork: false, allowFsWrite: false, allowSubprocess: false} + | Yard => {level, allowNetwork: true, allowFsWrite: false, allowSubprocess: false} + | Hunt => {level, allowNetwork: true, allowFsWrite: true, allowSubprocess: true} } } -pub type Target = { - os: Option, - is_edge: Bool, - requires_podman: Bool, - memory: Option, +// --------------------------------------------------------------------------- +// Target platform +// --------------------------------------------------------------------------- + +/// Target platform constraints for a K9 component. +struct target { { + os: option, + isEdge: bool, + requiresPodman: bool, + memory: option, } -pub type Recipes = { - install: Option, - validate: Option, - deploy: Option, - migrate: Option, - custom: [(String, String)], +// --------------------------------------------------------------------------- +// Recipes +// --------------------------------------------------------------------------- + +/// Collection of standard lifecycle recipes for a K9 component. +struct recipes { { + install: option, + validate: option, + deploy: option, + migrate: option, + custom: array<(string, string)>, } -pub fn empty_recipes() -> Recipes { - Recipes { install: None, validate: None, deploy: None, migrate: None, custom: [] } +/// Create an empty recipes collection. +fn emptyRecipes = (): recipes => { + install: None, + validate: None, + deploy: None, + migrate: None, + custom: [], } -pub type Validation = { - checksum: String, - pedigree_version: String, - hunt_authorized: Bool, +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/// Self-validation block for a K9 component. +struct validation { { + checksum: string, + pedigreeVersion: string, + huntAuthorized: bool, } -pub type ContractClause = { - clause_type: String, - predicate: String, - verified: Bool, +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + +/// A single clause within a K9 contract. +struct contractClause { { + clauseType: string, + predicate: string, + verified: bool, } -pub type Contract = { - name: String, - clauses: [ContractClause], +/// A contract attached to a K9 component (from the contractile system). +struct contract { { + name: string, + clauses: array, } -pub type Component = { - pedigree: Pedigree, - security: SecurityPolicy, - target: Option, - recipes: Option, - validation: Option, - content: [(String, String)], - tags: [String], +// --------------------------------------------------------------------------- +// Component (top-level AST node) +// --------------------------------------------------------------------------- + +/// A K9 self-validating component. This is the top-level AST node +/// representing a complete .k9 specification file. +struct component { { + pedigree: pedigree, + security: securityPolicy, + target: option, + recipes: option, + validation: option, + content: array<(string, string)>, + tags: array, } -pub fn make_component(pedigree: Pedigree, security_level: SecurityLevel) -> Component { - Component { - pedigree: pedigree, - security: default_security_policy(security_level), - target: None, - recipes: None, - validation: None, - content: [], - tags: [], - } +/// Create a minimal component with the given pedigree and security level. +fn makeComponent = ( + ~pedigree: pedigree, + ~securityLevel: securityLevel, +): component => { + pedigree, + security: defaultSecurityPolicy(securityLevel), + target: None, + recipes: None, + validation: None, + content: [], + tags: [], } -pub type ParseError = +// --------------------------------------------------------------------------- +// Parse errors +// --------------------------------------------------------------------------- + +/// Errors that can occur during K9 parsing. +struct parseError { | MissingMagicNumber - | MissingPedigree(String) - | InvalidSecurityLevel(String) - | UnexpectedToken(Int, String) + | MissingPedigree(string) + | InvalidSecurityLevel(string) + | UnexpectedToken({line: int, token: string}) | EmptyDocument -pub fn parse_error_to_string(err: ParseError) -> String { - match err { - MissingMagicNumber => "error[K9]: missing K9! magic number at start of file", - MissingPedigree(field) => "error[K9]: missing required pedigree field \"" ++ field ++ "\"", - InvalidSecurityLevel(lv) => "error[K9]: invalid security level \"" ++ lv ++ "\"", - UnexpectedToken(line, t) => "error[K9]: line " ++ show(line) ++ ": unexpected token \"" ++ t ++ "\"", - EmptyDocument => "error[K9]: document is empty", +/// Format a parse error as a diagnostic string. +fn parseErrorToString = (err: parseError): string => { + switch err { + | MissingMagicNumber => "error[K9]: missing K9! magic number at start of file" + | MissingPedigree(field) => `error[K9]: missing required pedigree field "${field}"` + | InvalidSecurityLevel(level) => `error[K9]: invalid security level "${level}"` + | UnexpectedToken({line, token}) => + `error[K9]: line ${line->Int.toString}: unexpected token "${token}"` + | EmptyDocument => "error[K9]: document is empty" } } + diff --git a/lol/src/Lang1000.affine b/lol/src/Lang1000.affine index d514d14e1..a1568bfa0 100644 --- a/lol/src/Lang1000.affine +++ b/lol/src/Lang1000.affine @@ -1,31 +1,26 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// 1000Langs - super-parallel corpus crawler CLI. -// AffineScript port of Lang1000.res. +// Ported via Harvard Engine (Semantic pass) module Lang1000; -use CorpusAnalyzer; -use Export; -use Crawler; -use BibleCloud; -use BibleCom; -use PngScriptures; -use EBible; -use FindBible; - -extern fn console_log(msg: String) -> Unit = "console" "log"; -extern fn console_error(msg: String) -> Unit = "console" "error"; -extern fn deno_args() -> [String] = "Deno" "args"; -extern fn deno_exit(code: Int) -> Unit = "Deno" "exit"; - -module Config { - pub let version = "0.1.0"; - pub let name = "1000Langs"; - pub let description = "Super-parallel corpus crawler for multilingual NLP research"; - - pub type Source = +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors + +/** + * 1000Langs - Super-Parallel Corpus Crawler + * + * A multilingual corpus building system supporting 1500+ languages + * from parallel Bible translations across multiple sources. + * + * CLI interface for Deno runtime with VeriSimDB quality verification. + */ + +module Config = { + fn version = "0.1.0" + fn name = "1000Langs" + fn description = "Super-parallel corpus crawler for multilingual NLP research" + + struct source { | BibleCloud | BibleCom | BibleIs @@ -33,268 +28,274 @@ module Config { | EBible | FindBible - pub let all_sources = [BibleCloud, BibleCom, BibleIs, PngScriptures, EBible, FindBible]; + fn allSources = [BibleCloud, BibleCom, BibleIs, PngScriptures, EBible, FindBible] - pub fn source_to_string(s: Source) -> String { - match s { - BibleCloud => "bible.cloud", - BibleCom => "bible.com", - BibleIs => "bible.is", - PngScriptures => "pngscriptures.org", - EBible => "ebible.org", - FindBible => "find.bible", + fn sourceToString = source => + switch source { + | BibleCloud => "bible.cloud" + | BibleCom => "bible.com" + | BibleIs => "bible.is" + | PngScriptures => "pngscriptures.org" + | EBible => "ebible.org" + | FindBible => "find.bible" } - } - pub fn source_from_string(s: String) -> Option { - match s { - "bible.cloud" => Some(BibleCloud), - "bible_cloud" => Some(BibleCloud), - "biblecloud" => Some(BibleCloud), - "bible.com" => Some(BibleCom), - "bible_com" => Some(BibleCom), - "biblecom" => Some(BibleCom), - "bible.is" => Some(BibleIs), - "bible_is" => Some(BibleIs), - "bibleis" => Some(BibleIs), - "pngscriptures" => Some(PngScriptures), - "pngscriptures.org" => Some(PngScriptures), - "png_scriptures" => Some(PngScriptures), - "ebible" => Some(EBible), - "ebible.org" => Some(EBible), - "e_bible" => Some(EBible), - "find.bible" => Some(FindBible), - "find_bible" => Some(FindBible), - "findbible" => Some(FindBible), - _ => None, + fn sourceFromString = str => + switch str { + | "bible.cloud" | "bible_cloud" | "biblecloud" => Some(BibleCloud) + | "bible.com" | "bible_com" | "biblecom" => Some(BibleCom) + | "bible.is" | "bible_is" | "bibleis" => Some(BibleIs) + | "pngscriptures" | "pngscriptures.org" | "png_scriptures" => Some(PngScriptures) + | "ebible" | "ebible.org" | "e_bible" => Some(EBible) + | "find.bible" | "find_bible" | "findbible" => Some(FindBible) + | _ => None } - } } -module Language { - pub type Iso639_3 = String - pub type LanguageName = String +module Language = { + struct iso639_3 { string + struct languageName { string - pub type T = { - code: Iso639_3, - name: LanguageName, - family: Option, - script: Option, - country: Option, + struct t { { + code: iso639_3, + name: languageName, + family: option, + script: option, + country: option, } - pub fn make(code: Iso639_3, name: LanguageName, family: Option, - script: Option, country: Option) -> T { - T { code: code, name: name, family: family, script: script, country: country } + fn make = (~code, ~name, ~family=?, ~script=?, ~country=?, ()) => { + code, + name, + family, + script, + country, } - pub fn get_code(lang: T) -> Iso639_3 { lang.code } - pub fn get_name(lang: T) -> LanguageName { lang.name } + fn getCode = lang => lang.code + fn getName = lang => lang.name } -module Verse { - pub type Book = String - pub type Chapter = Int - pub type VerseNum = Int +module Verse = { + struct book { string + struct chapter { int + struct verseNum { int - pub type Reference = { book: Book, chapter: Chapter, verse: VerseNum } - - pub type T = { - reference: Reference, - text: String, - language: Language.Iso639_3, + struct reference { { + book: book, + chapter: chapter, + verse: verseNum, } - pub fn make_reference(book: Book, chapter: Chapter, verse: VerseNum) -> Reference { - Reference { book: book, chapter: chapter, verse: verse } + struct t { { + reference: reference, + text: string, + language: Language.iso639_3, } - pub fn make(reference: Reference, text: String, language: Language.Iso639_3) -> T { - T { reference: reference, text: text, language: language } - } + fn makeReference = (~book, ~chapter, ~verse) => {book, chapter, verse} - pub fn to_canonical_id(r: Reference) -> String { - r.book ++ "." ++ show(r.chapter) ++ "." ++ show(r.verse) - } + fn make = (~reference, ~text, ~language) => {reference, text, language} + + fn toCanonicalId = ref => + `${ref.book}.${Int.toString(ref.chapter)}.${Int.toString(ref.verse)}` } -module Corpus { - pub type Alignment = { - reference_id: String, - translations: Dict, +module Corpus = { + struct alignment { { + referenceId: string, + translations: Dict.t, } - pub type T = { - name: String, - languages: [Language.T], - alignments: [Alignment], - metadata: Dict, + struct t { { + name: string, + languages: array, + alignments: array, + metadata: Dict.t, } - pub fn empty(name: String) -> T { - T { name: name, languages: [], alignments: [], metadata: dict_empty() } + fn empty = name => { + name, + languages: [], + alignments: [], + metadata: Dict.make(), } - pub fn add_language(corpus: T, lang: Language.T) -> T { - T { ...corpus, languages: corpus.languages ++ [lang] } + fn addLanguage = (corpus, lang) => { + ...corpus, + languages: Array.concat(corpus.languages, [lang]), } - pub fn add_alignment(corpus: T, alignment: Alignment) -> T { - T { ...corpus, alignments: corpus.alignments ++ [alignment] } + fn addAlignment = (corpus, alignment) => { + ...corpus, + alignments: Array.concat(corpus.alignments, [alignment]), } - pub fn language_count(corpus: T) -> Int { len(corpus.languages) } - pub fn alignment_count(corpus: T) -> Int { len(corpus.alignments) } + fn languageCount = corpus => Array.length(corpus.languages) + fn alignmentCount = corpus => Array.length(corpus.alignments) } -module Cli { - pub type Command = - | Crawl(Config.Source, String, Option) - | Verify(Option) +/** Deno CLI argument access */ +@val @scope("Deno") external args: array = "args" +@val @scope("Deno") external exit: int => unit = "exit" + +module Cli = { + struct command { + | Crawl({source: Config.source, lang: string, output: option}) + | Verify({output: option}) | ListSources | Help | Version - pub fn get_arg(args: [String], flag: String) -> Option { - let idx = array_find_index(args, flag); - if idx >= 0 && idx + 1 < len(args) { - Some(args[idx + 1]) + fn getArg = (args: array, flag: string): option => { + fn idx = args->Array.findIndex(a => a == flag) + if idx >= 0 && idx + 1 < Array.length(args) { + Some(Array.getUnsafe(args, idx + 1)) } else { None } } - pub fn parse_args(argv: [String]) -> Command { - if len(argv) == 0 { + fn parseArgs = (argv: array): command => { + if Array.length(argv) == 0 { Help } else { - match argv[0] { - "crawl" => { - let source_str = match get_arg(argv, "--source") { Some(s) => s, None => "bible.cloud" }; - let lang = match get_arg(argv, "--lang") { Some(l) => l, None => "eng" }; - let output = get_arg(argv, "--output"); - match Config.source_from_string(source_str) { - Some(source) => Crawl(source, lang, output), - None => { console_error("Unknown source: " ++ source_str); Help } - } + fn cmd = Array.getUnsafe(argv, 0) + switch cmd { + | "crawl" => + fn sourceStr = getArg(argv, "--source")->Option.getOr("bible.cloud") + fn lang = getArg(argv, "--lang")->Option.getOr("eng") + fn output = getArg(argv, "--output") + switch Config.sourceFromString(sourceStr) { + | Some(source) => Crawl({source, lang, output}) + | None => + Console.error(`Unknown source: ${sourceStr}`) + Help } - "verify" => Verify(get_arg(argv, "--output")), - "list-sources" => ListSources, - "version" => Version, - "--version" => Version, - "-v" => Version, - _ => Help, + | "verify" => + fn output = getArg(argv, "--output") + Verify({output}) + | "list-sources" => ListSources + | "version" | "--version" | "-v" => Version + | "help" | "--help" | "-h" | _ => Help } } } - pub fn print_help() -> Effect[IO] Unit { - console_log(Config.name ++ " v" ++ Config.version); - console_log(Config.description); - console_log(""); - console_log("Usage:"); - console_log(" 1000langs crawl --source --lang [--output ]"); - console_log(" 1000langs verify [--output ]"); - console_log(" 1000langs list-sources"); - console_log(" 1000langs version"); - console_log(" 1000langs help"); - console_log(""); - console_log("Sources:"); - let i = 0; - while i < len(Config.all_sources) { - console_log(" " ++ Config.source_to_string(Config.all_sources[i])); - i = i + 1; - } + fn printHelp = () => { + Console.log(`${Config.name} v${Config.version}`) + Console.log(Config.description) + Console.log("") + Console.log("Usage:") + Console.log(" 1000langs crawl --source --lang [--output ]") + Console.log(" 1000langs verify [--output ]") + Console.log(" 1000langs list-sources") + Console.log(" 1000langs version") + Console.log(" 1000langs help") + Console.log("") + Console.log("Sources:") + Config.allSources->Array.forEach(s => { + Console.log(` ${Config.sourceToString(s)}`) + }) } - pub fn print_version() -> Effect[IO] Unit { - console_log(Config.name ++ " v" ++ Config.version) + fn printVersion = () => { + Console.log(`${Config.name} v${Config.version}`) } - pub fn print_sources() -> Effect[IO] Unit { - console_log("Available corpus sources:"); - let i = 0; - while i < len(Config.all_sources) { - console_log(" " ++ Config.source_to_string(Config.all_sources[i])); - i = i + 1; - } + fn printSources = () => { + Console.log("Available corpus sources:") + Config.allSources->Array.forEach(s => { + Console.log(` ${Config.sourceToString(s)}`) + }) } - pub fn run_verify(output: Option) -> Effect[Async] Unit { - console_log("Running corpus quality verification..."); - let corpus = Corpus.empty("1000langs-corpus"); - let result = CorpusAnalyzer.analyze_full(corpus, "eng", "lol", "0.1.0"); - let output_path = match output { Some(o) => o, None => "/tmp/lol-scan.json" }; - await Export.write_to_file(result, output_path); - console_log("Scan written to: " ++ output_path); - console_log("Weak points: " ++ show(len(result.weak_points))) + fn runVerify = async (output: option) => { + Console.log("Running corpus quality verification...") + // Create a test corpus for demonstration + fn corpus = Corpus.empty("1000langs-corpus") + fn result = CorpusAnalyzer.analyzeFull(corpus, ()) + fn outputPath = output->Option.getOr("/tmp/lol-scan.json") + await Export.writeToFile(result, outputPath) + Console.log(`Scan written to: ${outputPath}`) + Console.log( + `Weak points: ${Int.toString(Array.length(result.weak_points))}`, + ) } - pub fn run_crawl(source: Config.Source, lang: String, output: Option) -> Effect[Async] Unit { - console_log("Crawling " ++ Config.source_to_string(source) ++ " for language: " ++ lang); - let _ = output; - match source { - BibleCloud => { - console_log("Using BibleCloud API crawler..."); - let crawler = BibleCloud.Crawler_.make(None); - match await BibleCloud.Crawler_.fetch_bibles(crawler) { - Crawler.Types.Success(bibles) => console_log("Found " ++ show(len(bibles)) ++ " Bibles"), - Crawler.Types.Failure(msg) => console_error("Crawl failed: " ++ msg), - Crawler.Types.Pending => console_log("Crawl pending (no API key configured)"), - } + fn runCrawl = async (source: Config.source, lang: string, output: option) => { + Console.log( + `Crawling ${Config.sourceToString(source)} for language: ${lang}`, + ) + fn _ = output + switch source { + | BibleCloud => + Console.log("Using BibleCloud API crawler...") + fn crawler = BibleCloud.Crawler.make() + fn result = await BibleCloud.Crawler.fetchBibles(crawler) + switch result { + | Crawler.Types.Success(bibles) => + Console.log(`Found ${Int.toString(Array.length(bibles))} Bibles`) + | Crawler.Types.Failure(msg) => Console.error(`Crawl failed: ${msg}`) + | Crawler.Types.Pending => Console.log("Crawl pending (no API key configured)") } - BibleCom => { - console_log("Using BibleCom web scraper..."); - let crawler = BibleCom.Crawler_.make(); - match await BibleCom.Crawler_.fetch_versions(crawler) { - Crawler.Types.Success(versions) => console_log("Found " ++ show(len(versions)) ++ " versions"), - Crawler.Types.Failure(msg) => console_error("Crawl failed: " ++ msg), - Crawler.Types.Pending => {}, - } + | BibleCom => + Console.log("Using BibleCom web scraper...") + fn crawler = BibleCom.Crawler.make() + fn result = await BibleCom.Crawler.fetchVersions(crawler) + switch result { + | Crawler.Types.Success(versions) => + Console.log(`Found ${Int.toString(Array.length(versions))} versions`) + | Crawler.Types.Failure(msg) => Console.error(`Crawl failed: ${msg}`) + | Crawler.Types.Pending => () } - PngScriptures => { - console_log("Using PNG Scriptures crawler..."); - let crawler = PngScriptures.Crawler_.make("./downloads/png"); - match await PngScriptures.Crawler_.fetch_languages(crawler) { - Crawler.Types.Success(langs) => console_log("Found " ++ show(len(langs)) ++ " languages"), - Crawler.Types.Failure(msg) => console_error("Crawl failed: " ++ msg), - Crawler.Types.Pending => {}, - } + | PngScriptures => + Console.log("Using PNG Scriptures crawler...") + fn crawler = PngScriptures.Crawler.make() + fn result = await PngScriptures.Crawler.fetchLanguages(crawler) + switch result { + | Crawler.Types.Success(langs) => + Console.log(`Found ${Int.toString(Array.length(langs))} languages`) + | Crawler.Types.Failure(msg) => Console.error(`Crawl failed: ${msg}`) + | Crawler.Types.Pending => () } - EBible => { - console_log("Using eBible.org crawler..."); - let crawler = EBible.Crawler_.make(); - match await EBible.Crawler_.fetch_translations(crawler) { - Crawler.Types.Success(translations) => console_log("Found " ++ show(len(translations)) ++ " translations"), - Crawler.Types.Failure(msg) => console_error("Crawl failed: " ++ msg), - Crawler.Types.Pending => {}, - } - } - FindBible => { - console_log("Using Find.Bible crawler..."); - let crawler = FindBible.Crawler_.make(); - match await FindBible.Crawler_.fetch_languages(crawler) { - Crawler.Types.Success(langs) => console_log("Found " ++ show(len(langs)) ++ " languages"), - Crawler.Types.Failure(msg) => console_error("Crawl failed: " ++ msg), - Crawler.Types.Pending => {}, - } + | EBible => + Console.log("Using eBible.org crawler...") + fn crawler = EBible.Crawler.make() + fn result = await EBible.Crawler.fetchTranslations(crawler) + switch result { + | Crawler.Types.Success(translations) => + Console.log(`Found ${Int.toString(Array.length(translations))} translations`) + | Crawler.Types.Failure(msg) => Console.error(`Crawl failed: ${msg}`) + | Crawler.Types.Pending => () } - BibleIs => { - console_log("BibleIs uses the Digital Bible Platform API..."); - console_log("Configure API key via BIBLE_API_KEY environment variable") + | FindBible => + Console.log("Using Find.Bible crawler...") + fn crawler = FindBible.Crawler.make() + fn result = await FindBible.Crawler.fetchLanguages(crawler) + switch result { + | Crawler.Types.Success(langs) => + Console.log(`Found ${Int.toString(Array.length(langs))} languages`) + | Crawler.Types.Failure(msg) => Console.error(`Crawl failed: ${msg}`) + | Crawler.Types.Pending => () } + | BibleIs => + Console.log("BibleIs uses the Digital Bible Platform API...") + Console.log("Configure API key via BIBLE_API_KEY environment variable") } } } -pub fn main() -> Effect[Async] Unit { - match Cli.parse_args(deno_args()) { - Cli.Help => Cli.print_help(), - Cli.Version => Cli.print_version(), - Cli.ListSources => Cli.print_sources(), - Cli.Verify(output) => await Cli.run_verify(output), - Cli.Crawl(source, lang, output) => await Cli.run_crawl(source, lang, output), +fn main = async () => { + fn cmd = Cli.parseArgs(args) + switch cmd { + | Cli.Help => Cli.printHelp() + | Cli.Version => Cli.printVersion() + | Cli.ListSources => Cli.printSources() + | Cli.Verify({output}) => await Cli.runVerify(output) + | Cli.Crawl({source, lang, output}) => await Cli.runCrawl(source, lang, output) } } -main() +ignore(main()) + diff --git a/lol/src/api/DigitalBiblePlatform.affine b/lol/src/api/DigitalBiblePlatform.affine index f3dfcb3bf..0cdf3de94 100644 --- a/lol/src/api/DigitalBiblePlatform.affine +++ b/lol/src/api/DigitalBiblePlatform.affine @@ -1,367 +1,385 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// Digital Bible Platform API wrapper. AffineScript port of DigitalBiblePlatform.res. +// Ported via Harvard Engine (Semantic pass) module DigitalBiblePlatform; -use Crawler; -use Http; - -extern fn json_parse_exn(s: String) -> Json = "JSON" "parseExn"; -extern fn json_as_object(j: Json) -> Option> = "json" "asObject"; -extern fn json_as_array(j: Json) -> Option<[Json]> = "json" "asArray"; -extern fn json_data_field(j: Json) -> Option = "json" "dataField"; -extern fn json_str_field(o: Dict, key: String) -> String = "json" "strField"; -extern fn json_opt_str_field(o: Dict, key: String) -> Option = "json" "optStrField"; -extern fn json_int_field(o: Dict, key: String) -> Int = "json" "intField"; -extern fn json_str_array_field(o: Dict, key: String) -> [String] = "json" "strArrayField"; -extern fn json_obj_array(j: Json) -> Option<[Dict]> = "json" "objArray"; - -module Config { - pub let api_base_url = "https://api.scripture.api.bible/v1"; - pub let cdn_base_url = "https://cdn.scripture.api.bible"; - - pub type Environment = | Production | Sandbox - - pub fn get_base_url(env: Environment) -> String { - match env { - Production => api_base_url, - Sandbox => "https://api-sandbox.scripture.api.bible/v1", +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors + +/** + * Digital Bible Platform API + * + * Official API wrapper for the Digital Bible Platform, + * providing access to Bible translations in 1500+ languages. + * Implements all 6 API methods with proper error handling. + */ + +module Config = { + fn apiBaseUrl = "https://api.scripture.api.bible/v1" + fn cdnBaseUrl = "https://cdn.scripture.api.bible" + + struct environment { + | Production + | Sandbox + + fn getBaseUrl = env => + switch env { + | Production => apiBaseUrl + | Sandbox => "https://api-sandbox.scripture.api.bible/v1" } - } } -module Types { - pub type ScriptDirection = | Ltr | Rtl - - pub type Language = { - id: String, - name: String, - name_local: String, - script: String, - script_direction: ScriptDirection, +module Types = { + struct language { { + id: string, + name: string, + nameLocal: string, + script: string, + scriptDirection: [#ltr | #rtl], } - pub type Chapter = { - id: String, - bible_id: String, - book_id: String, - number: String, - reference: String, + struct bible { { + id: string, + dblId: string, + abbreviation: string, + abbreviationLocal: string, + name: string, + nameLocal: string, + description: option, + descriptionLocal: option, + language: language, + countries: array, + struct_: string, } - pub type Book = { - id: String, - bible_id: String, - abbreviation: String, - name: String, - name_long: String, - chapters: [Chapter], + struct book { { + id: string, + bibleId: string, + abbreviation: string, + name: string, + nameLong: string, + chapters: array, + } + and chapter = { + id: string, + bibleId: string, + bookId: string, + number: string, + reference: string, } - pub type Bible = { - id: String, - dbl_id: String, - abbreviation: String, - abbreviation_local: String, - name: String, - name_local: String, - description: Option, - description_local: Option, - language: Language, - countries: [String], - type_: String, + struct verse { { + id: string, + orgId: string, + bibleId: string, + bookId: string, + chapterId: string, + reference: string, + content: string, } - pub type Verse = { - id: String, - org_id: String, - bible_id: String, - book_id: String, - chapter_id: String, - reference: String, - content: String, + struct passage { { + id: string, + bibleId: string, + orgId: string, + reference: string, + content: string, + verseCount: int, + copyright: string, } - pub type Passage = { - id: String, - bible_id: String, - org_id: String, - reference: String, - content: String, - verse_count: Int, - copyright: String, + struct apiResponse<'a> = { + data: 'a, + meta: option<{ + fums: string, + fumsId: string, + fumsJs: string, + }>, } - pub type ApiError = { - status_code: Int, - error: String, - message: String, + struct apiError { { + statusCode: int, + error: string, + message: string, } } -module JsonHelpers { - pub fn get_direction(obj: Dict, key: String) -> Types.ScriptDirection { - match json_opt_str_field(obj, key) { - Some("RTL") => Types.Rtl, - Some("rtl") => Types.Rtl, - _ => Types.Ltr, +module JsonHelpers = { + fn getString = (obj: Dict.t, key: string): string => + switch obj->Dict.get(key) { + | Some(String(s)) => s + | _ => "" + } + + fn getOptString = (obj: Dict.t, key: string): option => + switch obj->Dict.get(key) { + | Some(String(s)) => Some(s) + | _ => None + } + + fn getInt = (obj: Dict.t, key: string): int => + switch obj->Dict.get(key) { + | Some(Number(n)) => Float.toInt(n) + | _ => 0 + } + + fn getStringArray = (obj: Dict.t, key: string): array => + switch obj->Dict.get(key) { + | Some(Array(arr)) => arr->Array.filterMap(v => switch v { + | String(s) => Some(s) + | _ => None + }) + | _ => [] + } + + fn getDirection = (obj: Dict.t, key: string): [#ltr | #rtl] => + switch obj->Dict.get(key) { + | Some(String("RTL")) | Some(String("rtl")) => #rtl + | _ => #ltr } - } } -module Client { - use Types; +module Client = { + open Types + open JsonHelpers - pub type T = { - api_key: String, - environment: Config.Environment, - rate_limiter: Crawler.RateLimiter.T, + struct t { { + apiKey: string, + environment: Config.environment, + rateLimiter: Crawler.RateLimiter.t, } - pub fn make(api_key: String, environment: Config.Environment) -> T { - T { - api_key: api_key, - environment: environment, - rate_limiter: Crawler.RateLimiter.make(100), - } + fn make = (~apiKey, ~environment=Config.Production, ()) => { + apiKey, + environment, + rateLimiter: Crawler.RateLimiter.make(~delayMs=100, ()), } - pub fn get_auth_headers(client: T) -> Dict { - let headers = dict_empty(); - dict_set(headers, "api-key", client.api_key); - dict_set(headers, "Accept", "application/json"); + fn getAuthHeaders = client => { + fn headers = Dict.make() + Dict.set(headers, "api-key", client.apiKey) + Dict.set(headers, "Accept", "application/json") headers } - pub fn make_error(code: Int, msg: String) -> Types.ApiError { - Types.ApiError { status_code: code, error: "Error", message: msg } + fn makeError = (code, msg): apiError => { + statusCode: code, + error: "Error", + message: msg, } - pub fn api_request(client: T, url: String) -> Effect[Async] Result { - let headers = get_auth_headers(client); - let resp = await Http.get_with_rate_limit(url, Some(headers), client.rate_limiter); - match resp { - Ok(r) => { - try { - let json = json_parse_exn(r.body); - match json_data_field(json) { Some(data) => Ok(data), None => Ok(json) } - } catch _e { - Err(make_error(500, "Invalid JSON response")) + /** Generic API request helper */ + fn apiRequest = async (client: t, url: string): result => { + fn headers = getAuthHeaders(client) + fn resp = await Http.getWithRateLimit(url, ~headers, ~rateLimiter=client.rateLimiter, ()) + switch resp { + | Ok({body}) => + try { + fn json = JSON.parseExn(body) + switch json { + | Object(obj) => + switch obj->Dict.get("data") { + | Some(data) => Ok(data) + | None => Ok(json) + } + | _ => Ok(json) } + } catch { + | _ => Error(makeError(500, "Invalid JSON response")) } - Err(Http.HttpError(code, msg)) => Err(make_error(code, msg)), - Err(Http.NetworkError(msg)) => Err(make_error(0, msg)), - Err(Http.TimeoutError) => Err(make_error(408, "Request timed out")), - Err(Http.ParseError(msg)) => Err(make_error(422, msg)), + | Error(Http.HttpError(code, msg)) => Error(makeError(code, msg)) + | Error(Http.NetworkError(msg)) => Error(makeError(0, msg)) + | Error(Http.TimeoutError) => Error(makeError(408, "Request timed out")) + | Error(Http.ParseError(msg)) => Error(makeError(422, msg)) } } - pub fn parse_language(obj: Dict) -> Types.Language { - Types.Language { - id: json_str_field(obj, "id"), - name: json_str_field(obj, "name"), - name_local: json_str_field(obj, "nameLocal"), - script: json_str_field(obj, "script"), - script_direction: JsonHelpers.get_direction(obj, "scriptDirection"), - } + fn parseLanguage = (obj: Dict.t): language => { + id: getString(obj, "id"), + name: getString(obj, "name"), + nameLocal: getString(obj, "nameLocal"), + script: getString(obj, "script"), + scriptDirection: getDirection(obj, "scriptDirection"), } - pub fn parse_bible(obj: Dict) -> Types.Bible { - let lang = match json_as_object(json_obj_or_null(obj, "language")) { - Some(l) => parse_language(l), - None => Types.Language { id: "", name: "", name_local: "", script: "", script_direction: Types.Ltr }, - }; - Types.Bible { - id: json_str_field(obj, "id"), - dbl_id: json_str_field(obj, "dblId"), - abbreviation: json_str_field(obj, "abbreviation"), - abbreviation_local: json_str_field(obj, "abbreviationLocal"), - name: json_str_field(obj, "name"), - name_local: json_str_field(obj, "nameLocal"), - description: json_opt_str_field(obj, "description"), - description_local: json_opt_str_field(obj, "descriptionLocal"), + fn parseBible = (obj: Dict.t): bible => { + fn lang = switch obj->Dict.get("language") { + | Some(Object(l)) => parseLanguage(l) + | _ => {id: "", name: "", nameLocal: "", script: "", scriptDirection: #ltr} + } + { + id: getString(obj, "id"), + dblId: getString(obj, "dblId"), + abbreviation: getString(obj, "abbreviation"), + abbreviationLocal: getString(obj, "abbreviationLocal"), + name: getString(obj, "name"), + nameLocal: getString(obj, "nameLocal"), + description: getOptString(obj, "description"), + descriptionLocal: getOptString(obj, "descriptionLocal"), language: lang, - countries: json_str_array_field(obj, "countries"), - type_: json_str_field(obj, "type"), + countries: getStringArray(obj, "countries"), + struct_: getString(obj, "struct"), } } - fn query_string(params: [String]) -> String { - if len(params) == 0 { - "" + /** Get list of available Bibles, optionally filtered */ + fn getBibles = async ( + client: t, + ~language: option=?, + ~abbreviation: option=?, + (), + ): result, apiError> => { + fn baseUrl = Config.getBaseUrl(client.environment) + fn params = [] + switch language { + | Some(l) => ignore(Array.concat(params, [`language=${l}`])) + | None => () + } + switch abbreviation { + | Some(a) => ignore(Array.concat(params, [`abbreviation=${a}`])) + | None => () + } + fn queryStr = if Array.length(params) > 0 { + "?" ++ params->Array.join("&") } else { - let out = "?"; - let i = 0; - while i < len(params) { - out = if i == 0 { out ++ params[i] } else { out ++ "&" ++ params[i] }; - i = i + 1; - } - out + "" } - } - - pub fn get_bibles(client: T, language: Option, - abbreviation: Option) -> Effect[Async] Result<[Types.Bible], Types.ApiError> { - let base_url = Config.get_base_url(client.environment); - let params = []; - match language { Some(l) => { params = params ++ ["language=" ++ l]; } None => {} } - match abbreviation { Some(a) => { params = params ++ ["abbreviation=" ++ a]; } None => {} } - match await api_request(client, base_url ++ "/bibles" ++ query_string(params)) { - Ok(data) => { - match json_obj_array(data) { - Some(objs) => { - let out = []; - let i = 0; - while i < len(objs) { out = out ++ [parse_bible(objs[i])]; i = i + 1; } - Ok(out) - } - None => Err(make_error(500, "Unexpected response format")), - } - } - Err(e) => Err(e), + fn resp = await apiRequest(client, `${baseUrl}/bibles${queryStr}`) + switch resp { + | Ok(Array(arr)) => + Ok(arr->Array.filterMap(item => switch item { + | Object(obj) => Some(parseBible(obj)) + | _ => None + })) + | Ok(_) => Error(makeError(500, "Unexpected response format")) + | Error(e) => Error(e) } } - pub fn get_bible(client: T, bible_id: String) -> Effect[Async] Result { - let base_url = Config.get_base_url(client.environment); - match await api_request(client, base_url ++ "/bibles/" ++ bible_id) { - Ok(data) => { - match json_as_object(data) { - Some(obj) => Ok(parse_bible(obj)), - None => Err(make_error(500, "Unexpected response format")), - } - } - Err(e) => Err(e), + /** Get a specific Bible by ID */ + fn getBible = async (client: t, ~bibleId: string): result => { + fn baseUrl = Config.getBaseUrl(client.environment) + fn resp = await apiRequest(client, `${baseUrl}/bibles/${bibleId}`) + switch resp { + | Ok(Object(obj)) => Ok(parseBible(obj)) + | Ok(_) => Error(makeError(500, "Unexpected response format")) + | Error(e) => Error(e) } } - pub fn get_books(client: T, bible_id: String) -> Effect[Async] Result<[Types.Book], Types.ApiError> { - let base_url = Config.get_base_url(client.environment); - match await api_request(client, base_url ++ "/bibles/" ++ bible_id ++ "/books") { - Ok(data) => { - match json_obj_array(data) { - Some(objs) => { - let out = []; - let i = 0; - while i < len(objs) { - let obj = objs[i]; - let chapters = []; - match json_obj_array(json_obj_or_null(obj, "chapters")) { - Some(chs) => { - let c = 0; - while c < len(chs) { - let ch = chs[c]; - chapters = chapters ++ [Types.Chapter { - id: json_str_field(ch, "id"), - bible_id: json_str_field(ch, "bibleId"), - book_id: json_str_field(ch, "bookId"), - number: json_str_field(ch, "number"), - reference: json_str_field(ch, "reference"), - }]; - c = c + 1; - } - } - None => {} - } - out = out ++ [Types.Book { - id: json_str_field(obj, "id"), - bible_id: json_str_field(obj, "bibleId"), - abbreviation: json_str_field(obj, "abbreviation"), - name: json_str_field(obj, "name"), - name_long: json_str_field(obj, "nameLong"), - chapters: chapters, - }]; - i = i + 1; - } - Ok(out) - } - None => Err(make_error(500, "Unexpected response format")), + /** Get books for a Bible */ + fn getBooks = async (client: t, ~bibleId: string): result, apiError> => { + fn baseUrl = Config.getBaseUrl(client.environment) + fn resp = await apiRequest(client, `${baseUrl}/bibles/${bibleId}/books`) + switch resp { + | Ok(Array(arr)) => + Ok(arr->Array.filterMap(item => switch item { + | Object(obj) => + fn chapters = switch obj->Dict.get("chapters") { + | Some(Array(chArr)) => chArr->Array.filterMap(ch => switch ch { + | Object(chObj) => Some({ + id: getString(chObj, "id"), + bibleId: getString(chObj, "bibleId"), + bookId: getString(chObj, "bookId"), + number: getString(chObj, "number"), + reference: getString(chObj, "reference"), + }) + | _ => None + }) + | _ => [] } - } - Err(e) => Err(e), + Some({ + id: getString(obj, "id"), + bibleId: getString(obj, "bibleId"), + abbreviation: getString(obj, "abbreviation"), + name: getString(obj, "name"), + nameLong: getString(obj, "nameLong"), + chapters, + }) + | _ => None + })) + | Ok(_) => Error(makeError(500, "Unexpected response format")) + | Error(e) => Error(e) } } - pub fn get_chapters(client: T, bible_id: String, book_id: String) -> Effect[Async] Result<[Types.Chapter], Types.ApiError> { - let base_url = Config.get_base_url(client.environment); - match await api_request(client, base_url ++ "/bibles/" ++ bible_id ++ "/books/" ++ book_id ++ "/chapters") { - Ok(data) => { - match json_obj_array(data) { - Some(objs) => { - let out = []; - let i = 0; - while i < len(objs) { - let o = objs[i]; - out = out ++ [Types.Chapter { - id: json_str_field(o, "id"), - bible_id: json_str_field(o, "bibleId"), - book_id: json_str_field(o, "bookId"), - number: json_str_field(o, "number"), - reference: json_str_field(o, "reference"), - }]; - i = i + 1; - } - Ok(out) - } - None => Err(make_error(500, "Unexpected response format")), - } - } - Err(e) => Err(e), + /** Get chapters for a book */ + fn getChapters = async ( + client: t, + ~bibleId: string, + ~bookId: string, + ): result, apiError> => { + fn baseUrl = Config.getBaseUrl(client.environment) + fn resp = await apiRequest(client, `${baseUrl}/bibles/${bibleId}/books/${bookId}/chapters`) + switch resp { + | Ok(Array(arr)) => + Ok(arr->Array.filterMap(item => switch item { + | Object(obj) => Some({ + id: getString(obj, "id"), + bibleId: getString(obj, "bibleId"), + bookId: getString(obj, "bookId"), + number: getString(obj, "number"), + reference: getString(obj, "reference"), + }) + | _ => None + })) + | Ok(_) => Error(makeError(500, "Unexpected response format")) + | Error(e) => Error(e) } } - pub fn get_verses(client: T, bible_id: String, chapter_id: String) -> Effect[Async] Result<[Types.Verse], Types.ApiError> { - let base_url = Config.get_base_url(client.environment); - match await api_request(client, base_url ++ "/bibles/" ++ bible_id ++ "/chapters/" ++ chapter_id ++ "/verses") { - Ok(data) => { - match json_obj_array(data) { - Some(objs) => { - let out = []; - let i = 0; - while i < len(objs) { - let o = objs[i]; - out = out ++ [Types.Verse { - id: json_str_field(o, "id"), - org_id: json_str_field(o, "orgId"), - bible_id: json_str_field(o, "bibleId"), - book_id: json_str_field(o, "bookId"), - chapter_id: json_str_field(o, "chapterId"), - reference: json_str_field(o, "reference"), - content: json_str_field(o, "content"), - }]; - i = i + 1; - } - Ok(out) - } - None => Err(make_error(500, "Unexpected response format")), - } - } - Err(e) => Err(e), + /** Get verses for a chapter */ + fn getVerses = async ( + client: t, + ~bibleId: string, + ~chapterId: string, + ): result, apiError> => { + fn baseUrl = Config.getBaseUrl(client.environment) + fn resp = await apiRequest( + client, + `${baseUrl}/bibles/${bibleId}/chapters/${chapterId}/verses`, + ) + switch resp { + | Ok(Array(arr)) => + Ok(arr->Array.filterMap(item => switch item { + | Object(obj) => Some({ + id: getString(obj, "id"), + orgId: getString(obj, "orgId"), + bibleId: getString(obj, "bibleId"), + bookId: getString(obj, "bookId"), + chapterId: getString(obj, "chapterId"), + reference: getString(obj, "reference"), + content: getString(obj, "content"), + }) + | _ => None + })) + | Ok(_) => Error(makeError(500, "Unexpected response format")) + | Error(e) => Error(e) } } - pub fn get_passage(client: T, bible_id: String, passage_id: String) -> Effect[Async] Result { - let base_url = Config.get_base_url(client.environment); - match await api_request(client, base_url ++ "/bibles/" ++ bible_id ++ "/passages/" ++ passage_id) { - Ok(data) => { - match json_as_object(data) { - Some(obj) => Ok(Types.Passage { - id: json_str_field(obj, "id"), - bible_id: json_str_field(obj, "bibleId"), - org_id: json_str_field(obj, "orgId"), - reference: json_str_field(obj, "reference"), - content: json_str_field(obj, "content"), - verse_count: json_int_field(obj, "verseCount"), - copyright: json_str_field(obj, "copyright"), - }), - None => Err(make_error(500, "Unexpected response format")), - } - } - Err(e) => Err(e), + /** Get a passage (range of verses) */ + fn getPassage = async ( + client: t, + ~bibleId: string, + ~passageId: string, + ): result => { + fn baseUrl = Config.getBaseUrl(client.environment) + fn resp = await apiRequest(client, `${baseUrl}/bibles/${bibleId}/passages/${passageId}`) + switch resp { + | Ok(Object(obj)) => + Ok({ + id: getString(obj, "id"), + bibleId: getString(obj, "bibleId"), + orgId: getString(obj, "orgId"), + reference: getString(obj, "reference"), + content: getString(obj, "content"), + verseCount: getInt(obj, "verseCount"), + copyright: getString(obj, "copyright"), + }) + | Ok(_) => Error(makeError(500, "Unexpected response format")) + | Error(e) => Error(e) } } } -extern fn json_obj_or_null(o: Dict, key: String) -> Json = "json" "fieldOrNull"; diff --git a/lol/src/crawlers/BibleCloud.affine b/lol/src/crawlers/BibleCloud.affine index 58051a865..0e7072d51 100644 --- a/lol/src/crawlers/BibleCloud.affine +++ b/lol/src/crawlers/BibleCloud.affine @@ -1,254 +1,298 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// Bible.cloud crawler (Digital Bible Platform). AffineScript port of BibleCloud.res. +// Ported via Harvard Engine (Semantic pass) module BibleCloud; -use Crawler; -use Http; -use Lang1000; - -extern fn json_parse_exn(s: String) -> Json = "JSON" "parseExn"; -extern fn json_data_array(j: Json) -> Option<[Dict]> = "json" "dataObjArray"; -extern fn json_str_field(o: Dict, key: String) -> String = "json" "strField"; -extern fn json_opt_str_field(o: Dict, key: String) -> Option = "json" "optStrField"; -extern fn json_obj_field(o: Dict, key: String) -> Dict = "json" "objField"; -extern fn str_split(s: String, sep: String) -> [String] = "string" "split"; -extern fn str_trim(s: String) -> String = "string" "trim"; -extern fn str_to_int(s: String) -> Option = "string" "toInt"; - -module Config { - pub let base_url = "https://api.scripture.api.bible/v1"; - pub let web_url = "https://bible.cloud"; - pub let api_version = "v1"; - pub let rate_limit_ms = 500; - - pub type ApiCredentials = { api_key: String, api_secret: Option } +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors + +/** + * Bible.cloud Crawler + * + * Crawler implementation for the Digital Bible Platform (bible.cloud) + * API-based access to Bible translations in 1500+ languages. + */ + +open Crawler.Types + +module Config = { + fn baseUrl = "https://api.scripture.api.bible/v1" + fn webUrl = "https://bible.cloud" + fn apiVersion = "v1" + fn rateLimitMs = 500 + + struct apiCredentials { { + apiKey: string, + apiSecret: option, + } +} + +module Endpoints = { + fn bibles = () => `${Config.baseUrl}/bibles` + fn bible = bibleId => `${Config.baseUrl}/bibles/${bibleId}` + fn books = bibleId => `${Config.baseUrl}/bibles/${bibleId}/books` + fn chapters = (bibleId, bookId) => + `${Config.baseUrl}/bibles/${bibleId}/books/${bookId}/chapters` + fn verses = (bibleId, chapterId) => + `${Config.baseUrl}/bibles/${bibleId}/chapters/${chapterId}/verses` + fn verse = (bibleId, verseId) => `${Config.baseUrl}/bibles/${bibleId}/verses/${verseId}` } -module Endpoints { - pub fn bibles() -> String { Config.base_url ++ "/bibles" } - pub fn bible(id: String) -> String { Config.base_url ++ "/bibles/" ++ id } - pub fn books(id: String) -> String { Config.base_url ++ "/bibles/" ++ id ++ "/books" } - pub fn chapters(id: String, book_id: String) -> String { - Config.base_url ++ "/bibles/" ++ id ++ "/books/" ++ book_id ++ "/chapters" +module Types = { + struct bibleInfo { { + id: string, + name: string, + nameLocal: string, + language: Lang1000.Language.t, + description: option, + copyright: option, } - pub fn verses(id: String, chapter_id: String) -> String { - Config.base_url ++ "/bibles/" ++ id ++ "/chapters/" ++ chapter_id ++ "/verses" + + struct bookInfo { { + id: string, + bibleId: string, + abbreviation: string, + name: string, + nameLong: string, } - pub fn verse(id: String, verse_id: String) -> String { - Config.base_url ++ "/bibles/" ++ id ++ "/verses/" ++ verse_id + + struct chapterInfo { { + id: string, + bibleId: string, + bookId: string, + number: string, + reference: string, } } -module Types { - pub type BibleInfo = { - id: String, - name: String, - name_local: String, - language: Lang1000.Language.T, - description: Option, - copyright: Option, +module Parser = { + open Types + + /** Parse the data array from an API response JSON */ + fn getDataArray = (json: JSON.t): option> => { + switch json { + | Object(obj) => + switch obj->Dict.get("data") { + | Some(Array(arr)) => Some(arr) + | _ => None + } + | _ => None + } } - pub type BookInfo = { - id: String, - bible_id: String, - abbreviation: String, - name: String, - name_long: String, + /** Extract a string field from a JSON object */ + fn getString = (obj: Dict.t, key: string): string => { + switch obj->Dict.get(key) { + | Some(String(s)) => s + | _ => "" + } } - pub type ChapterInfo = { - id: String, - bible_id: String, - book_id: String, - number: String, - reference: String, + fn getOptString = (obj: Dict.t, key: string): option => { + switch obj->Dict.get(key) { + | Some(String(s)) => Some(s) + | _ => None + } } -} -module Parser { - use Types; - - pub fn parse_bible_list(json: Json) -> Crawler.Parser.ParseResult<[Types.BibleInfo]> { - match json_data_array(json) { - None => Crawler.Parser.NoMatch, - Some(arr) => { - let bibles = []; - let i = 0; - while i < len(arr) { - let obj = arr[i]; - let lang_obj = json_obj_field(obj, "language"); - bibles = bibles ++ [Types.BibleInfo { - id: json_str_field(obj, "id"), - name: json_str_field(obj, "name"), - name_local: json_str_field(obj, "nameLocal"), + fn parseBibleList = (json: JSON.t): Crawler.Parser.parseResult> => { + switch getDataArray(json) { + | None => Crawler.Parser.NoMatch + | Some(arr) => + fn bibles = arr->Array.filterMap(item => { + switch item { + | Object(obj) => + fn langObj = switch obj->Dict.get("language") { + | Some(Object(l)) => l + | _ => Dict.make() + } + Some({ + id: getString(obj, "id"), + name: getString(obj, "name"), + nameLocal: getString(obj, "nameLocal"), language: Lang1000.Language.make( - json_str_field(lang_obj, "id"), - json_str_field(lang_obj, "name"), - None, - Some(json_str_field(lang_obj, "script")), - None), - description: json_opt_str_field(obj, "description"), - copyright: json_opt_str_field(obj, "copyright"), - }]; - i = i + 1; + ~code=getString(langObj, "id"), + ~name=getString(langObj, "name"), + ~script=getString(langObj, "script"), + (), + ), + description: getOptString(obj, "description"), + copyright: getOptString(obj, "copyright"), + }) + | _ => None } - Crawler.Parser.Parsed(bibles) - } + }) + Crawler.Parser.Parsed(bibles) } } - pub fn parse_books(json: Json) -> Crawler.Parser.ParseResult<[Types.BookInfo]> { - match json_data_array(json) { - None => Crawler.Parser.NoMatch, - Some(arr) => { - let books = []; - let i = 0; - while i < len(arr) { - let o = arr[i]; - books = books ++ [Types.BookInfo { - id: json_str_field(o, "id"), - bible_id: json_str_field(o, "bibleId"), - abbreviation: json_str_field(o, "abbreviation"), - name: json_str_field(o, "name"), - name_long: json_str_field(o, "nameLong"), - }]; - i = i + 1; + fn parseBooks = (json: JSON.t): Crawler.Parser.parseResult> => { + switch getDataArray(json) { + | None => Crawler.Parser.NoMatch + | Some(arr) => + fn books = arr->Array.filterMap(item => { + switch item { + | Object(obj) => + Some({ + id: getString(obj, "id"), + bibleId: getString(obj, "bibleId"), + abbreviation: getString(obj, "abbreviation"), + name: getString(obj, "name"), + nameLong: getString(obj, "nameLong"), + }) + | _ => None } - Crawler.Parser.Parsed(books) - } + }) + Crawler.Parser.Parsed(books) } } - pub fn parse_chapters(json: Json) -> Crawler.Parser.ParseResult<[Types.ChapterInfo]> { - match json_data_array(json) { - None => Crawler.Parser.NoMatch, - Some(arr) => { - let chapters = []; - let i = 0; - while i < len(arr) { - let o = arr[i]; - chapters = chapters ++ [Types.ChapterInfo { - id: json_str_field(o, "id"), - bible_id: json_str_field(o, "bibleId"), - book_id: json_str_field(o, "bookId"), - number: json_str_field(o, "number"), - reference: json_str_field(o, "reference"), - }]; - i = i + 1; + fn parseChapters = (json: JSON.t): Crawler.Parser.parseResult> => { + switch getDataArray(json) { + | None => Crawler.Parser.NoMatch + | Some(arr) => + fn chapters = arr->Array.filterMap(item => { + switch item { + | Object(obj) => + Some({ + id: getString(obj, "id"), + bibleId: getString(obj, "bibleId"), + bookId: getString(obj, "bookId"), + number: getString(obj, "number"), + reference: getString(obj, "reference"), + }) + | _ => None } - Crawler.Parser.Parsed(chapters) - } + }) + Crawler.Parser.Parsed(chapters) } } - pub fn parse_verses(json: Json, language_code: String) -> Crawler.Parser.ParseResult<[Lang1000.Verse.T]> { - match json_data_array(json) { - None => Crawler.Parser.NoMatch, - Some(arr) => { - let verses = []; - let i = 0; - while i < len(arr) { - let o = arr[i]; - let reference = json_str_field(o, "reference"); - let content = json_str_field(o, "content"); - let parts = str_split(reference, "."); - if len(parts) >= 3 { - let chapter = match str_to_int(parts[1]) { Some(n) => n, None => 1 }; - let verse = match str_to_int(parts[2]) { Some(n) => n, None => 1 }; - verses = verses ++ [Lang1000.Verse.make( - Lang1000.Verse.make_reference(parts[0], chapter, verse), - str_trim(content), language_code)]; + fn parseVerses = (json: JSON.t, languageCode: string): Crawler.Parser.parseResult< + array, + > => { + switch getDataArray(json) { + | None => Crawler.Parser.NoMatch + | Some(arr) => + fn verses = arr->Array.filterMap(item => { + switch item { + | Object(obj) => + fn ref = getString(obj, "reference") + fn content = getString(obj, "content") + // Parse reference like "GEN.1.1" into book/chapter/verse + fn parts = ref->String.split(".") + if Array.length(parts) >= 3 { + fn book = Array.getUnsafe(parts, 0) + fn chapter = Array.getUnsafe(parts, 1)->Int.fromString->Option.getOr(1) + fn verse = Array.getUnsafe(parts, 2)->Int.fromString->Option.getOr(1) + Some( + Lang1000.Verse.make( + ~reference=Lang1000.Verse.makeReference(~book, ~chapter, ~verse), + ~text=content->String.trim, + ~language=languageCode, + ), + ) + } else { + None } - i = i + 1; + | _ => None } - Crawler.Parser.Parsed(verses) - } + }) + Crawler.Parser.Parsed(verses) } } } -module Crawler_ { - use Crawler.Types; - - pub type T = { - credentials: Option, - rate_limiter: Crawler.RateLimiter.T, - mut state: Crawler.Types.CrawlerState, +module Crawler = { + struct t { { + credentials: option, + rateLimiter: Crawler.RateLimiter.t, + mutable state: crawlerState, } - pub fn make(api_key: Option) -> T { - let creds = match api_key { - Some(key) => Some(Config.ApiCredentials { api_key: key, api_secret: None }), - None => None, - }; - T { - credentials: creds, - rate_limiter: Crawler.RateLimiter.make(Config.rate_limit_ms), - state: Crawler.Types.Idle, - } + fn make = (~apiKey=?, ()) => { + credentials: apiKey->Option.map(key => {Config.apiKey: key, apiSecret: None}), + rateLimiter: Crawler.RateLimiter.make(~delayMs=Config.rateLimitMs, ()), + state: Idle, } - pub fn get_headers(crawler: T) -> Dict { - let headers = Crawler.Config.make_default_headers(); - match crawler.credentials { - Some(c) => dict_set(headers, "api-key", c.api_key), - None => {}, + fn getHeaders = (crawler: t): Dict.t => { + fn headers = Crawler.Config.makeDefaultHeaders() + switch crawler.credentials { + | Some({apiKey}) => Dict.set(headers, "api-key", apiKey) + | None => () } - dict_set(headers, "Accept", "application/json"); + Dict.set(headers, "Accept", "application/json") headers } - pub fn fetch_bibles(crawler: T) -> Effect[Async] Crawler.Types.CrawlResult<[Types.BibleInfo]> { - crawler.state = Crawler.Types.Crawling("bibles"); - let headers = get_headers(crawler); - let resp = await Http.get_with_rate_limit(Endpoints.bibles(), Some(headers), crawler.rate_limiter); - crawler.state = Crawler.Types.Idle; - match resp { - Ok(r) => { - match Parser.parse_bible_list(json_parse_exn(r.body)) { - Crawler.Parser.Parsed(bibles) => Crawler.Types.Success(bibles), - _ => Crawler.Types.Failure("Failed to parse bibles list"), - } + fn fetchBibles = async (crawler: t): crawlResult> => { + crawler.state = Crawling("bibles") + fn headers = getHeaders(crawler) + fn resp = await Http.getWithRateLimit( + Endpoints.bibles(), + ~headers, + ~rateLimiter=crawler.rateLimiter, + (), + ) + crawler.state = Idle + switch resp { + | Ok({body}) => + fn json = JSON.parseExn(body) + switch Parser.parseBibleList(json) { + | Crawler.Parser.Parsed(bibles) => Success(bibles) + | _ => Failure("Failed to parse bibles list") } - Err(Http.HttpError(code, msg)) => Crawler.Types.Failure("HTTP " ++ show(code) ++ ": " ++ msg), - Err(Http.NetworkError(msg)) => Crawler.Types.Failure("Network error: " ++ msg), - Err(Http.TimeoutError) => Crawler.Types.Failure("Request timed out"), - Err(Http.ParseError(msg)) => Crawler.Types.Failure("Parse error: " ++ msg), + | Error(Http.HttpError(code, msg)) => Failure(`HTTP ${Int.toString(code)}: ${msg}`) + | Error(Http.NetworkError(msg)) => Failure(`Network error: ${msg}`) + | Error(Http.TimeoutError) => Failure("Request timed out") + | Error(Http.ParseError(msg)) => Failure(`Parse error: ${msg}`) } } - pub fn fetch_books(crawler: T, bible_id: String) -> Effect[Async] Crawler.Types.CrawlResult<[Types.BookInfo]> { - let headers = get_headers(crawler); - let resp = await Http.get_with_rate_limit(Endpoints.books(bible_id), Some(headers), crawler.rate_limiter); - match resp { - Ok(r) => { - match Parser.parse_books(json_parse_exn(r.body)) { - Crawler.Parser.Parsed(books) => Crawler.Types.Success(books), - _ => Crawler.Types.Failure("Failed to parse books"), - } + fn fetchBooks = async (crawler: t, bibleId: string): crawlResult> => { + fn headers = getHeaders(crawler) + fn resp = await Http.getWithRateLimit( + Endpoints.books(bibleId), + ~headers, + ~rateLimiter=crawler.rateLimiter, + (), + ) + switch resp { + | Ok({body}) => + fn json = JSON.parseExn(body) + switch Parser.parseBooks(json) { + | Crawler.Parser.Parsed(books) => Success(books) + | _ => Failure("Failed to parse books") } - Err(Http.HttpError(code, msg)) => Crawler.Types.Failure("HTTP " ++ show(code) ++ ": " ++ msg), - Err(_) => Crawler.Types.Failure("Request failed"), + | Error(Http.HttpError(code, msg)) => Failure(`HTTP ${Int.toString(code)}: ${msg}`) + | Error(_) => Failure("Request failed") } } - pub fn fetch_chapter(crawler: T, bible_id: String, chapter_id: String, - language_code: String) -> Effect[Async] Crawler.Types.CrawlResult<[Lang1000.Verse.T]> { - let headers = get_headers(crawler); - let resp = await Http.get_with_rate_limit(Endpoints.verses(bible_id, chapter_id), Some(headers), crawler.rate_limiter); - match resp { - Ok(r) => { - match Parser.parse_verses(json_parse_exn(r.body), language_code) { - Crawler.Parser.Parsed(verses) => Crawler.Types.Success(verses), - _ => Crawler.Types.Failure("Failed to parse verses"), - } + fn fetchChapter = async ( + crawler: t, + bibleId: string, + chapterId: string, + languageCode: string, + ): crawlResult> => { + fn headers = getHeaders(crawler) + fn resp = await Http.getWithRateLimit( + Endpoints.verses(bibleId, chapterId), + ~headers, + ~rateLimiter=crawler.rateLimiter, + (), + ) + switch resp { + | Ok({body}) => + fn json = JSON.parseExn(body) + switch Parser.parseVerses(json, languageCode) { + | Crawler.Parser.Parsed(verses) => Success(verses) + | _ => Failure("Failed to parse verses") } - Err(Http.HttpError(code, msg)) => Crawler.Types.Failure("HTTP " ++ show(code) ++ ": " ++ msg), - Err(_) => Crawler.Types.Failure("Request failed"), + | Error(Http.HttpError(code, msg)) => Failure(`HTTP ${Int.toString(code)}: ${msg}`) + | Error(_) => Failure("Request failed") } } } + diff --git a/lol/src/crawlers/BibleCom.affine b/lol/src/crawlers/BibleCom.affine index a64b2f5bd..312420bd1 100644 --- a/lol/src/crawlers/BibleCom.affine +++ b/lol/src/crawlers/BibleCom.affine @@ -1,210 +1,266 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// Bible.com (YouVersion) web-scraping crawler. AffineScript port of BibleCom.res. +// Ported via Harvard Engine (Semantic pass) module BibleCom; -use Crawler; -use Http; -use Lang1000; - -extern fn json_parse_exn(s: String) -> Json = "JSON" "parseExn"; -extern fn json_data_array(j: Json) -> Option<[Dict]> = "json" "dataObjArray"; -extern fn json_str_field(o: Dict, key: String) -> String = "json" "strField"; -extern fn json_int_field(o: Dict, key: String) -> Int = "json" "intField"; -extern fn json_bool_field(o: Dict, key: String) -> Bool = "json" "boolField"; -extern fn str_replace_regex(s: String, pattern: String, repl: String) -> String = "string" "replaceRegExp"; -extern fn str_trim(s: String) -> String = "string" "trim"; -extern fn str_split(s: String, sep: String) -> [String] = "string" "split"; - -module Config { - pub let base_url = "https://www.bible.com"; - pub let api_url = "https://www.bible.com/api/bible"; - pub let rate_limit_ms = 2000; - - pub type VersionInfo = { - id: Int, - abbreviation: String, - title: String, - language_tag: String, +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors + +/** + * Bible.com Crawler + * + * Crawler implementation for YouVersion (bible.com) + * Web scraping approach for Bible translations. + * Rate limited to 2 req/sec to respect server limits. + */ + +open Crawler.Types + +module Config = { + fn baseUrl = "https://www.bible.com" + fn apiUrl = "https://www.bible.com/api/bible" + fn rateLimitMs = 2000 + + struct versionInfo { { + id: int, + abbreviation: string, + title: string, + languageTag: string, } } -module Endpoints { - pub fn versions() -> String { Config.base_url ++ "/versions" } - pub fn version(id: Int) -> String { Config.base_url ++ "/versions/" ++ show(id) } - pub fn bible(id: Int, book_code: String, chapter: Int) -> String { - Config.base_url ++ "/bible/" ++ show(id) ++ "/" ++ book_code ++ "." ++ show(chapter) - } - pub fn search(id: Int, query: String) -> String { - Config.base_url ++ "/search/bible?version_id=" ++ show(id) ++ "&q=" ++ query - } - pub fn api_chapter(id: Int, book_code: String, chapter: Int) -> String { - Config.api_url ++ "/chapter/" ++ show(id) ++ "/" ++ book_code ++ "." ++ show(chapter) ++ ".json" - } +module Endpoints = { + fn versions = () => `${Config.baseUrl}/versions` + fn version = versionId => `${Config.baseUrl}/versions/${Int.toString(versionId)}` + fn bible = (versionId, bookCode, chapter) => + `${Config.baseUrl}/bible/${Int.toString(versionId)}/${bookCode}.${Int.toString(chapter)}` + fn search = (versionId, query) => + `${Config.baseUrl}/search/bible?version_id=${Int.toString(versionId)}&q=${query}` + fn apiChapter = (versionId, bookCode, chapter) => + `${Config.apiUrl}/chapter/${Int.toString(versionId)}/${bookCode}.${Int.toString(chapter)}.json` } -module Selectors { - pub let verse_container = ".ChapterContent_verse__uvbXo"; - pub let verse_number = ".ChapterContent_label__R2PLt"; - pub let verse_text = ".ChapterContent_content__RlRwn"; - pub let chapter_nav = ".ChapterContent_nav__vVPwy"; - pub let version_selector = ".VersionSelector_container__3uKjR"; +module Selectors = { + fn verseContainer = ".ChapterContent_verse__uvbXo" + fn verseNumber = ".ChapterContent_label__R2PLt" + fn verseText = ".ChapterContent_content__RlRwn" + fn chapterNav = ".ChapterContent_nav__vVPwy" + fn versionSelector = ".VersionSelector_container__3uKjR" } -module Types { - pub type VersionMeta = { - id: Int, - abbreviation: String, - title: String, - language: Lang1000.Language.T, - has_audio: Bool, - has_offline: Bool, +module Types = { + struct versionMeta { { + id: int, + abbreviation: string, + title: string, + language: Lang1000.Language.t, + hasAudio: bool, + hasOffline: bool, } - pub type ChapterContent = { - version_id: Int, - book: String, - chapter: Int, - verses: [Lang1000.Verse.T], - next_chapter: Option<(String, Int)>, - prev_chapter: Option<(String, Int)>, + struct chapterContent { { + versionId: int, + book: string, + chapter: int, + verses: array, + nextChapter: option<(string, int)>, + prevChapter: option<(string, int)>, } } -module Parser { - use Types; - - pub fn parse_version_list(json: Json) -> Crawler.Parser.ParseResult<[Types.VersionMeta]> { - match json_data_array(json) { - None => Crawler.Parser.NoMatch, - Some(arr) => { - let versions = []; - let i = 0; - while i < len(arr) { - let v = arr[i]; - let lang_tag = json_str_field(v, "language_tag"); - versions = versions ++ [Types.VersionMeta { - id: json_int_field(v, "id"), - abbreviation: json_str_field(v, "abbreviation"), - title: json_str_field(v, "title"), - language: Lang1000.Language.make(lang_tag, lang_tag, None, None, None), - has_audio: json_bool_field(v, "has_audio"), - has_offline: json_bool_field(v, "has_offline"), - }]; - i = i + 1; - } +module Parser = { + open Types + + /** Parse version listing from API JSON response */ + fn parseVersionList = (json: JSON.t): Crawler.Parser.parseResult> => { + switch json { + | Object(obj) => + switch obj->Dict.get("data") { + | Some(Array(arr)) => + fn versions = arr->Array.filterMap(item => { + switch item { + | Object(v) => + fn getString = (d: Dict.t, k: string) => + switch d->Dict.get(k) { + | Some(String(s)) => s + | _ => "" + } + fn getInt = (d: Dict.t, k: string) => + switch d->Dict.get(k) { + | Some(Number(n)) => Float.toInt(n) + | _ => 0 + } + fn getBool = (d: Dict.t, k: string) => + switch d->Dict.get(k) { + | Some(Boolean(b)) => b + | _ => false + } + fn langTag = getString(v, "language_tag") + Some({ + id: getInt(v, "id"), + abbreviation: getString(v, "abbreviation"), + title: getString(v, "title"), + language: Lang1000.Language.make(~code=langTag, ~name=langTag, ()), + hasAudio: getBool(v, "has_audio"), + hasOffline: getBool(v, "has_offline"), + }) + | _ => None + } + }) Crawler.Parser.Parsed(versions) + | _ => Crawler.Parser.NoMatch } + | _ => Crawler.Parser.NoMatch } } - pub fn strip_html_tags(html: String) -> String { - str_trim(str_replace_regex(str_replace_regex(html, "<[^>]+>", ""), "\\s+", " ")) + /** Extract clean text from HTML by stripping tags */ + fn stripHtmlTags = (html: string): string => { + html + ->String.replaceRegExp(%re("/<[^>]+>/g"), "") + ->String.replaceRegExp(%re("/\s+/g"), " ") + ->String.trim } - pub fn parse_chapter_html(html: String, version_id: Int, book: String, chapter: Int, - language_code: String) -> Crawler.Parser.ParseResult { - let verses = []; - let blocks = str_split(html, "data-usfm=\""); - let i = 1; - while i < len(blocks) { - let cleaned = strip_html_tags(blocks[i]); - if len(cleaned) > 0 { - verses = verses ++ [Lang1000.Verse.make( - Lang1000.Verse.make_reference(book, chapter, len(verses) + 1), - cleaned, language_code)]; + /** Parse chapter content from HTML page */ + fn parseChapterHtml = ( + html: string, + ~versionId: int, + ~book: string, + ~chapter: int, + ~languageCode: string, + (), + ): Crawler.Parser.parseResult => { + // Extract verse blocks using regex on HTML structure + fn verseRegex = %re( + "/data-usfm=\"([^\"]+)\"[^>]*>.*?]*class=\"[^\"]*content[^\"]*\"[^>]*>(.*?)<\/span>/gs" + ) + fn verses = [] + fn _ = html->String.replaceRegExp(verseRegex, (match_, _offset, _str) => { + // This is a simplified extraction - the regex captures verse refs and content + fn cleaned = stripHtmlTags(match_) + if String.length(cleaned) > 0 { + fn verse = Lang1000.Verse.make( + ~reference=Lang1000.Verse.makeReference(~book, ~chapter, ~verse=Array.length(verses) + 1), + ~text=cleaned, + ~language=languageCode, + ) + ignore(Array.concat(verses, [verse])) } - i = i + 1; - } - if len(verses) > 0 { - Crawler.Parser.Parsed(Types.ChapterContent { - version_id: version_id, book: book, chapter: chapter, - verses: verses, next_chapter: None, prev_chapter: None, + match_ + }) + + if Array.length(verses) > 0 { + Crawler.Parser.Parsed({ + versionId, + book, + chapter, + verses, + nextChapter: None, + prevChapter: None, }) } else { Crawler.Parser.NoMatch } } - pub fn normalize_text(text: String) -> String { - str_replace_regex(str_trim(text), "\\s+", " ") + fn normalizeText = (text: string): string => { + text + ->String.trim + ->String.replaceRegExp(%re("/\s+/g"), " ") } } -module Crawler_ { - use Crawler.Types; - - pub type T = { - rate_limiter: Crawler.RateLimiter.T, - mut state: Crawler.Types.CrawlerState, - mut cached_versions: Option<[Types.VersionMeta]>, +module Crawler = { + struct t { { + rateLimiter: Crawler.RateLimiter.t, + mutable state: crawlerState, + mutable cachedVersions: option>, } - pub fn make() -> T { - T { - rate_limiter: Crawler.RateLimiter.make(Config.rate_limit_ms), - state: Crawler.Types.Idle, - cached_versions: None, - } + fn make = () => { + rateLimiter: Crawler.RateLimiter.make(~delayMs=Config.rateLimitMs, ()), + state: Idle, + cachedVersions: None, } - pub fn fetch_versions(crawler: T) -> Effect[Async] Crawler.Types.CrawlResult<[Types.VersionMeta]> { - match crawler.cached_versions { - Some(versions) => Crawler.Types.Success(versions), - None => { - crawler.state = Crawler.Types.Crawling("versions"); - let resp = await Http.get_with_rate_limit(Endpoints.versions(), None, crawler.rate_limiter); - crawler.state = Crawler.Types.Idle; - match resp { - Ok(r) => { - match Parser.parse_version_list(json_parse_exn(r.body)) { - Crawler.Parser.Parsed(versions) => { - crawler.cached_versions = Some(versions); - Crawler.Types.Success(versions) - } - _ => Crawler.Types.Failure("Failed to parse versions"), - } - } - Err(Http.HttpError(code, msg)) => Crawler.Types.Failure("HTTP " ++ show(code) ++ ": " ++ msg), - Err(_) => Crawler.Types.Failure("Request failed"), + fn fetchVersions = async (crawler: t): crawlResult> => { + switch crawler.cachedVersions { + | Some(versions) => Success(versions) + | None => + crawler.state = Crawling("versions") + fn resp = await Http.getWithRateLimit( + Endpoints.versions(), + ~rateLimiter=crawler.rateLimiter, + (), + ) + crawler.state = Idle + switch resp { + | Ok({body}) => + switch Parser.parseVersionList(JSON.parseExn(body)) { + | Crawler.Parser.Parsed(versions) => + crawler.cachedVersions = Some(versions) + Success(versions) + | _ => Failure("Failed to parse versions") } + | Error(Http.HttpError(code, msg)) => Failure(`HTTP ${Int.toString(code)}: ${msg}`) + | Error(_) => Failure("Request failed") } } } - pub fn fetch_chapter(crawler: T, version_id: Int, book: String, chapter: Int, - language_code: String) -> Effect[Async] Crawler.Types.CrawlResult { - let resp = await Http.get_with_rate_limit(Endpoints.bible(version_id, book, chapter), None, crawler.rate_limiter); - match resp { - Ok(r) => { - match Parser.parse_chapter_html(r.body, version_id, book, chapter, language_code) { - Crawler.Parser.Parsed(content) => Crawler.Types.Success(content), - _ => Crawler.Types.Failure("Failed to parse chapter HTML"), - } + fn fetchChapter = async ( + crawler: t, + versionId: int, + book: string, + chapter: int, + languageCode: string, + ): crawlResult => { + fn resp = await Http.getWithRateLimit( + Endpoints.bible(versionId, book, chapter), + ~rateLimiter=crawler.rateLimiter, + (), + ) + switch resp { + | Ok({body}) => + switch Parser.parseChapterHtml(body, ~versionId, ~book, ~chapter, ~languageCode, ()) { + | Crawler.Parser.Parsed(content) => Success(content) + | _ => Failure("Failed to parse chapter HTML") } - Err(Http.HttpError(code, msg)) => Crawler.Types.Failure("HTTP " ++ show(code) ++ ": " ++ msg), - Err(_) => Crawler.Types.Failure("Request failed"), + | Error(Http.HttpError(code, msg)) => Failure(`HTTP ${Int.toString(code)}: ${msg}`) + | Error(_) => Failure("Request failed") } } - pub fn fetch_book(crawler: T, version_id: Int, book: String, total_chapters: Int, - language_code: String) -> Effect[Async] Crawler.Types.CrawlResult<[Types.ChapterContent]> { - let chapters = []; - let failed = false; - let fail_msg = ""; - let ch = 1; - while ch <= total_chapters { - if !failed { - match await fetch_chapter(crawler, version_id, book, ch, language_code) { - Crawler.Types.Success(content) => { chapters = chapters ++ [content]; } - Crawler.Types.Failure(msg) => { failed = true; fail_msg = msg; } - Crawler.Types.Pending => {} + fn fetchBook = async ( + crawler: t, + versionId: int, + book: string, + totalChapters: int, + languageCode: string, + ): crawlResult> => { + fn chapters = [] + fn failed = ref(false) + fn failMsg = ref("") + + for ch in 1 to totalChapters { + if !failed.contents { + fn result = await fetchChapter(crawler, versionId, book, ch, languageCode) + switch result { + | Success(content) => ignore(Array.concat(chapters, [content])) + | Failure(msg) => + failed := true + failMsg := msg + | Pending => () } } - ch = ch + 1; } - if failed { Crawler.Types.Failure(fail_msg) } else { Crawler.Types.Success(chapters) } + + if failed.contents { + Failure(failMsg.contents) + } else { + Success(chapters) + } } } + diff --git a/lol/src/crawlers/Crawler.affine b/lol/src/crawlers/Crawler.affine index 2e7d10055..ef03e8606 100644 --- a/lol/src/crawlers/Crawler.affine +++ b/lol/src/crawlers/Crawler.affine @@ -1,145 +1,146 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// Base crawler module. AffineScript port of Crawler.res. +// Ported via Harvard Engine (Semantic pass) module Crawler; -extern fn date_now() -> Float = "Date" "now"; -extern fn math_pow(base: Float, exp: Float) -> Float = "Math" "pow"; - -module Types { - pub type HttpMethod = | GET | POST | HEAD - - pub type RequestConfig = { - url: String, - method: HttpMethod, - headers: Dict, - timeout: Int, - retries: Int, +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors + +/** + * Base Crawler Module + * + * Provides the foundational structs and functions for web crawling + * Bible corpus sources. + */ + +module Types = { + struct httpMethod { GET | POST | HEAD + + struct requestConfig { { + url: string, + method: httpMethod, + headers: Dict.t, + timeout: int, + retries: int, } - pub type ResponseStatus = - | Ok(Int) - | NetworkError(String) + struct responseStatus { + | Ok(int) + | NetworkError(string) | Timeout | RateLimited | NotFound - pub type Response = { - status: ResponseStatus, - headers: Dict, - body: Option, + struct response { { + status: responseStatus, + headers: Dict.t, + body: option, } - pub type CrawlResult = - | Success(a) - | Failure(String) + struct crawlResult<'a> = + | Success('a) + | Failure(string) | Pending - pub type CrawlerState = + struct crawlerState { | Idle - | Crawling(String) + | Crawling(string) | Paused | Stopped - | StateError(String) + | Error(string) } -module Config { - pub let default_timeout = 30000; - pub let default_retries = 3; - pub let default_user_agent = "1000Langs/0.1.0 (Parallel Corpus Crawler; +https://github.com/Hyperpolymath/1000Langs)"; - pub let default_rate_limit_ms = 1000; - - pub fn make_default_headers() -> Dict { - let headers = dict_empty(); - dict_set(headers, "User-Agent", default_user_agent); - dict_set(headers, "Accept", "text/html,application/xhtml+xml,application/xml"); - dict_set(headers, "Accept-Language", "en-US,en;q=0.9"); +module Config = { + fn defaultTimeout = 30000 + fn defaultRetries = 3 + fn defaultUserAgent = "1000Langs/0.1.0 (Parallel Corpus Crawler; +https://github.com/Hyperpolymath/1000Langs)" + fn defaultRateLimitMs = 1000 + + fn makeDefaultHeaders = () => { + fn headers = Dict.make() + Dict.set(headers, "User-Agent", defaultUserAgent) + Dict.set(headers, "Accept", "text/html,application/xhtml+xml,application/xml") + Dict.set(headers, "Accept-Language", "en-US,en;q=0.9") headers } } -module Request { - use Types; - - pub fn make(url: String, method: Types.HttpMethod, headers: Option>, - timeout: Option, retries: Option) -> Types.RequestConfig { - Types.RequestConfig { - url: url, - method: method, - headers: match headers { Some(h) => h, None => Config.make_default_headers() }, - timeout: match timeout { Some(t) => t, None => Config.default_timeout }, - retries: match retries { Some(r) => r, None => Config.default_retries }, - } +module Request = { + open Types + + fn make = (~url, ~method=GET, ~headers=?, ~timeout=?, ~retries=?, ()) => { + url, + method, + headers: headers->Option.getOr(Config.makeDefaultHeaders()), + timeout: timeout->Option.getOr(Config.defaultTimeout), + retries: retries->Option.getOr(Config.defaultRetries), } - pub fn with_header(config: Types.RequestConfig, key: String, value: String) -> Types.RequestConfig { - dict_set(config.headers, key, value); + fn withHeader = (config, key, value) => { + Dict.set(config.headers, key, value) config } - pub fn method_to_string(m: Types.HttpMethod) -> String { - match m { GET => "GET", POST => "POST", HEAD => "HEAD" } + fn methodToString = method => switch method { + | GET => "GET" + | POST => "POST" + | HEAD => "HEAD" } } -module RateLimiter { - pub type T = { - mut last_request: Float, - delay_ms: Int, +module RateLimiter = { + struct t { { + mutable lastRequest: float, + delayMs: int, } - pub fn make(delay_ms: Int) -> T { - T { last_request: 0.0, delay_ms: delay_ms } + fn make = (~delayMs=Config.defaultRateLimitMs, ()) => { + lastRequest: 0.0, + delayMs, } - pub fn can_proceed(limiter: T) -> Bool { - let elapsed = date_now() -. limiter.last_request; - elapsed >= int_to_float(limiter.delay_ms) + fn canProceed = limiter => { + fn now = Date.now() + fn elapsed = now -. limiter.lastRequest + elapsed >= Float.fromInt(limiter.delayMs) } - pub fn record_request(limiter: T) -> Unit { - limiter.last_request = date_now() + fn recordRequest = limiter => { + limiter.lastRequest = Date.now() } } -module RetryPolicy { - pub type T = - | Constant(Int) - | Linear(Int) - | Exponential(Int, Float) - - pub fn calculate_delay(strategy: T, attempt: Int) -> Int { - match strategy { - Constant(ms) => ms, - Linear(base) => base * attempt, - Exponential(base, factor) => - float_to_int(int_to_float(base) *. math_pow(factor, int_to_float(attempt - 1))), - } +module RetryPolicy = { + struct backoffStrategy { + | Constant(int) + | Linear(int) + | Exponential(int, float) + + fn calculateDelay = (strategy, attempt) => switch strategy { + | Constant(ms) => ms + | Linear(base) => base * attempt + | Exponential(base, factor) => + Float.toInt(Float.fromInt(base) *. Math.pow(factor, ~exp=Float.fromInt(attempt - 1))) } - pub fn should_retry(attempt: Int, max_retries: Int) -> Bool { - attempt < max_retries - } + fn shouldRetry = (attempt, maxRetries) => attempt < maxRetries } -module Parser { - pub type Selector = - | Css(String) - | XPath(String) - | Regex(String) +module Parser = { + struct selector { + | Css(string) + | XPath(string) + | Regex(string) - pub type ParseResult = - | Parsed(a) - | ParseError(String) + struct parseResult<'a> = + | Parsed('a) + | ParseError(string) | NoMatch - pub fn selector_to_string(s: Selector) -> String { - match s { - Css(x) => "css:" ++ x, - XPath(x) => "xpath:" ++ x, - Regex(x) => "regex:" ++ x, - } + fn selectorToString = selector => switch selector { + | Css(s) => `css:${s}` + | XPath(s) => `xpath:${s}` + | Regex(s) => `regex:${s}` } } + diff --git a/lol/src/crawlers/EBible.affine b/lol/src/crawlers/EBible.affine index 909df3c60..7c81cef5d 100644 --- a/lol/src/crawlers/EBible.affine +++ b/lol/src/crawlers/EBible.affine @@ -1,159 +1,192 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// eBible.org crawler. AffineScript port of EBible.res. +// Ported via Harvard Engine (Semantic pass) module EBible; -use Crawler; -use Http; -use Lang1000; - -extern fn str_split(s: String, sep: String) -> [String] = "string" "split"; -extern fn str_trim(s: String) -> String = "string" "trim"; -extern fn str_starts_with(s: String, p: String) -> Bool = "string" "startsWith"; -extern fn str_slice_to_end(s: String, start: Int) -> String = "string" "sliceToEnd"; -extern fn str_slice(s: String, start: Int, end: Int) -> String = "string" "slice"; -extern fn str_index_of(s: String, needle: String) -> Int = "string" "indexOf"; -extern fn str_replace_regex(s: String, pattern: String, repl: String) -> String = "string" "replaceRegExp"; -extern fn str_to_int(s: String) -> Option = "string" "toInt"; - -module Config { - pub let base_url = "https://ebible.org"; - pub let download_url = "https://ebible.org/Scriptures"; - pub let rate_limit_ms = 1000; +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors + +/** + * eBible.org Crawler + * + * Crawler for ebible.org bulk Bible text collection. + * High coverage with 1000+ languages in USFM plain text format. + */ + +open Crawler.Types + +module Config = { + fn baseUrl = "https://ebible.org" + fn downloadUrl = "https://ebible.org/Scriptures" + fn rateLimitMs = 1000 } -module Endpoints { - pub fn translation_list() -> String { Config.base_url ++ "/Scriptures/" } - pub fn translation(id: String) -> String { Config.download_url ++ "/" ++ id ++ "/" } - pub fn usfm_zip(id: String) -> String { Config.download_url ++ "/" ++ id ++ "_usfm.zip" } - pub fn metadata(id: String) -> String { Config.download_url ++ "/" ++ id ++ "/copr.htm" } +module Endpoints = { + fn translationList = () => `${Config.baseUrl}/Scriptures/` + fn translation = translationId => `${Config.downloadUrl}/${translationId}/` + fn usfmZip = translationId => `${Config.downloadUrl}/${translationId}_usfm.zip` + fn metadata = translationId => `${Config.downloadUrl}/${translationId}/copr.htm` } -module Types { - pub type TranslationInfo = { - id: String, - language: String, - title: String, - copyright: Option, - completeness: Option, +module Types = { + struct translationInfo { { + id: string, + language: string, + title: string, + copyright: option, + completeness: option, } } -module Parser { - use Types; - - // Parse translation listing from the Scriptures index page. - pub fn parse_translation_list(html: String) -> Crawler.Parser.ParseResult<[Types.TranslationInfo]> { - let translations = []; - let entries = str_split(html, "href=\""); - let i = 1; - while i < len(entries) { - let seg = entries[i]; - let q = str_index_of(seg, "\""); - if q > 0 { - let raw = str_slice(seg, 0, q); - let id = str_replace_regex(raw, "/", ""); - if len(id) > 2 && !str_starts_with(id, ".") { - translations = translations ++ [Types.TranslationInfo { - id: id, language: id, title: id, copyright: None, completeness: None, - }]; +module Parser = { + open Types + + /** Parse translation listing from the Scriptures index page */ + fn parseTranslationList = (html: string): Crawler.Parser.parseResult< + array, + > => { + // Extract translation entries from directory listing + fn linkRegex = %re("/href=\"([a-zA-Z0-9_-]+)\/\"[^>]*>([^<]*)String.replaceRegExp(linkRegex, (match_, _offset, _str) => { + fn parts = match_->String.split("\"") + if Array.length(parts) >= 2 { + fn id = Array.getUnsafe(parts, 1)->String.replaceRegExp(%re("/\//"), "") + if String.length(id) > 2 && !String.startsWith(id, ".") { + ignore( + Array.concat( + translations, + [ + { + id, + language: id, + title: id, + copyright: None, + completeness: None, + }, + ], + ), + ) } } - i = i + 1; - } - if len(translations) > 0 { + match_ + }) + + if Array.length(translations) > 0 { Crawler.Parser.Parsed(translations) } else { Crawler.Parser.NoMatch } } - // Parse USFM content to extract verses. - pub fn parse_usfm(usfm: String, language_code: String) -> [Lang1000.Verse.T] { - let verses = []; - let current_book = ""; - let current_chapter = 0; - - let lines = str_split(usfm, "\n"); - let i = 0; - while i < len(lines) { - let trimmed = str_trim(lines[i]); - if str_starts_with(trimmed, "\\id ") { - current_book = str_split(str_slice_to_end(trimmed, 4), " ")[0]; + /** Parse USFM content to extract verses (inline, avoids cross-module coupling) */ + fn parseUsfm = (usfm: string, languageCode: string): array => { + fn verses: ref> = ref([]) + fn currentBook = ref("") + fn currentChapter = ref(0) + + fn lines = usfm->String.split("\n") + lines->Array.forEach(line => { + fn trimmed = String.trim(line) + if String.startsWith(trimmed, "\\id ") { + currentBook := String.sliceToEnd(trimmed, ~start=4)->String.split(" ")->Array.getUnsafe(0) } - if str_starts_with(trimmed, "\\c ") { - current_chapter = match str_to_int(str_trim(str_slice_to_end(trimmed, 3))) { - Some(n) => n, None => 0, - }; + if String.startsWith(trimmed, "\\c ") { + currentChapter := + String.sliceToEnd(trimmed, ~start=3) + ->String.trim + ->Int.fromString + ->Option.getOr(0) } - if str_starts_with(trimmed, "\\v ") { - let rest = str_slice_to_end(trimmed, 3); - let space_idx = str_index_of(rest, " "); - if space_idx > 0 { - let verse_num = match str_to_int(str_slice(rest, 0, space_idx)) { Some(n) => n, None => 0 }; - let text = str_trim(str_replace_regex(str_slice_to_end(rest, space_idx + 1), "\\\\[a-z]+\\s?", "")); - if verse_num > 0 && len(text) > 0 { - verses = verses ++ [Lang1000.Verse.make( - Lang1000.Verse.make_reference(current_book, current_chapter, verse_num), - text, language_code)]; + if String.startsWith(trimmed, "\\v ") { + fn rest = String.sliceToEnd(trimmed, ~start=3) + fn spaceIdx = String.indexOf(rest, " ") + if spaceIdx > 0 { + fn verseNum = String.slice(rest, ~start=0, ~end=spaceIdx)->Int.fromString->Option.getOr(0) + fn text = + String.sliceToEnd(rest, ~start=spaceIdx + 1) + ->String.replaceRegExp(%re("/\\\\[a-z]+\s?/g"), "") + ->String.trim + if verseNum > 0 && String.length(text) > 0 { + verses := + Array.concat( + verses.contents, + [ + Lang1000.Verse.make( + ~reference=Lang1000.Verse.makeReference( + ~book=currentBook.contents, + ~chapter=currentChapter.contents, + ~verse=verseNum, + ), + ~text, + ~language=languageCode, + ), + ], + ) } } } - i = i + 1; - } - verses + }) + verses.contents } } -module Crawler_ { - use Crawler.Types; - - pub type T = { - rate_limiter: Crawler.RateLimiter.T, - mut state: Crawler.Types.CrawlerState, +module Crawler = { + struct t { { + rateLimiter: Crawler.RateLimiter.t, + mutable state: crawlerState, } - pub fn make() -> T { - T { - rate_limiter: Crawler.RateLimiter.make(Config.rate_limit_ms), - state: Crawler.Types.Idle, - } + fn make = () => { + rateLimiter: Crawler.RateLimiter.make(~delayMs=Config.rateLimitMs, ()), + state: Idle, } - pub fn fetch_translations(crawler: T) -> Effect[Async] Crawler.Types.CrawlResult<[Types.TranslationInfo]> { - crawler.state = Crawler.Types.Crawling("translations"); - let resp = await Http.get_with_rate_limit(Endpoints.translation_list(), None, crawler.rate_limiter); - crawler.state = Crawler.Types.Idle; - match resp { - Ok(r) => { - match Parser.parse_translation_list(r.body) { - Crawler.Parser.Parsed(translations) => Crawler.Types.Success(translations), - _ => Crawler.Types.Failure("Failed to parse translation list"), - } + /** Fetch the list of available translations */ + fn fetchTranslations = async (crawler: t): crawlResult> => { + crawler.state = Crawling("translations") + fn resp = await Http.getWithRateLimit( + Endpoints.translationList(), + ~rateLimiter=crawler.rateLimiter, + (), + ) + crawler.state = Idle + switch resp { + | Ok({body}) => + switch Parser.parseTranslationList(body) { + | Crawler.Parser.Parsed(translations) => Success(translations) + | _ => Failure("Failed to parse translation list") } - Err(Http.HttpError(code, msg)) => Crawler.Types.Failure("HTTP " ++ show(code) ++ ": " ++ msg), - Err(_) => Crawler.Types.Failure("Request failed"), + | Error(Http.HttpError(code, msg)) => Failure(`HTTP ${Int.toString(code)}: ${msg}`) + | Error(_) => Failure("Request failed") } } - pub fn fetch_translation(crawler: T, translation_id: String, - language_code: String) -> Effect[Async] Crawler.Types.CrawlResult<[Lang1000.Verse.T]> { - crawler.state = Crawler.Types.Crawling(translation_id); - let resp = await Http.get_with_rate_limit(Endpoints.translation(translation_id), None, crawler.rate_limiter); - crawler.state = Crawler.Types.Idle; - match resp { - Ok(r) => { - let verses = Parser.parse_usfm(r.body, language_code); - if len(verses) > 0 { - Crawler.Types.Success(verses) - } else { - Crawler.Types.Failure("No verses extracted from USFM content") - } + /** Fetch and parse a specific translation's text */ + fn fetchTranslation = async ( + crawler: t, + translationId: string, + languageCode: string, + ): crawlResult> => { + crawler.state = Crawling(translationId) + // Fetch the raw USFM text from the translation page + fn resp = await Http.getWithRateLimit( + Endpoints.translation(translationId), + ~rateLimiter=crawler.rateLimiter, + (), + ) + crawler.state = Idle + switch resp { + | Ok({body}) => + fn verses = Parser.parseUsfm(body, languageCode) + if Array.length(verses) > 0 { + Success(verses) + } else { + Failure("No verses extracted from USFM content") } - Err(Http.HttpError(code, msg)) => Crawler.Types.Failure("HTTP " ++ show(code) ++ ": " ++ msg), - Err(_) => Crawler.Types.Failure("Request failed"), + | Error(Http.HttpError(code, msg)) => Failure(`HTTP ${Int.toString(code)}: ${msg}`) + | Error(_) => Failure("Request failed") } } } + diff --git a/lol/src/crawlers/FindBible.affine b/lol/src/crawlers/FindBible.affine index b9197bbd3..1f0229060 100644 --- a/lol/src/crawlers/FindBible.affine +++ b/lol/src/crawlers/FindBible.affine @@ -1,207 +1,255 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// Find.Bible crawler (API + HTML hybrid). AffineScript port of FindBible.res. +// Ported via Harvard Engine (Semantic pass) module FindBible; -use Crawler; -use Http; -use Lang1000; - -extern fn json_parse_exn(s: String) -> Json = "JSON" "parseExn"; -extern fn json_as_array(j: Json) -> Option<[Json]> = "json" "asArray"; -extern fn json_as_object(j: Json) -> Option> = "json" "asObject"; -extern fn json_str_field(o: Dict, key: String) -> String = "json" "strField"; -extern fn json_opt_str_field(o: Dict, key: String) -> Option = "json" "optStrField"; -extern fn json_int_field(o: Dict, key: String) -> Int = "json" "intField"; -extern fn json_opt_int_field(o: Dict, key: String) -> Option = "json" "optIntField"; - -module Config { - pub let base_url = "https://find.bible"; - pub let api_url = "https://find.bible/api"; - pub let rate_limit_ms = 1000; +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors + +/** + * Find.Bible Crawler + * + * Crawler for find.bible - API + HTML hybrid approach for + * language discovery and per-language Bible text retrieval. + */ + +open Crawler.Types + +module Config = { + fn baseUrl = "https://find.bible" + fn apiUrl = "https://find.bible/api" + fn rateLimitMs = 1000 } -module Endpoints { - pub fn languages() -> String { Config.api_url ++ "/languages" } - pub fn language(code: String) -> String { Config.api_url ++ "/languages/" ++ code } - pub fn bibles(code: String) -> String { Config.api_url ++ "/bibles?language=" ++ code } - pub fn bible(id: String) -> String { Config.api_url ++ "/bibles/" ++ id } - pub fn text(id: String, book: String, chapter: Int) -> String { - Config.api_url ++ "/bibles/" ++ id ++ "/" ++ book ++ "/" ++ show(chapter) - } +module Endpoints = { + fn languages = () => `${Config.apiUrl}/languages` + fn language = langCode => `${Config.apiUrl}/languages/${langCode}` + fn bibles = langCode => `${Config.apiUrl}/bibles?language=${langCode}` + fn bible = bibleId => `${Config.apiUrl}/bibles/${bibleId}` + fn text = (bibleId, book, chapter) => + `${Config.apiUrl}/bibles/${bibleId}/${book}/${Int.toString(chapter)}` } -module Types { - pub type LanguageInfo = { - code: String, - name: String, - native_name: Option, - bible_count: Int, +module Types = { + struct languageInfo { { + code: string, + name: string, + nativeName: option, + bibleCount: int, } - pub type BibleEntry = { - id: String, - title: String, - language_code: String, - year: Option, - copyright: Option, + struct bibleEntry { { + id: string, + title: string, + languageCode: string, + year: option, + copyright: option, } } -module Parser { - use Types; - - fn obj_array(json: Json, data_key: String) -> Option<[Dict]> { - match json_as_array(json) { - Some(arr) => Some(collect_objs(arr)), - None => { - match json_as_object(json) { - Some(obj) => { - match dict_get(obj, data_key) { - Some(inner) => match json_as_array(inner) { Some(a) => Some(collect_objs(a)), None => None }, - None => None, - } - } - None => None, - } - } +module Parser = { + open Types + + fn getString = (obj: Dict.t, key: string): string => + switch obj->Dict.get(key) { + | Some(String(s)) => s + | _ => "" } - } - fn collect_objs(arr: [Json]) -> [Dict] { - let out = []; - let i = 0; - while i < len(arr) { - match json_as_object(arr[i]) { Some(o) => { out = out ++ [o]; } None => {} } - i = i + 1; + fn getOptString = (obj: Dict.t, key: string): option => + switch obj->Dict.get(key) { + | Some(String(s)) => Some(s) + | _ => None } - out - } - pub fn parse_languages(json: Json) -> Crawler.Parser.ParseResult<[Types.LanguageInfo]> { - match obj_array(json, "data") { - Some(objs) => { - let langs = []; - let i = 0; - while i < len(objs) { - let o = objs[i]; - langs = langs ++ [Types.LanguageInfo { - code: json_str_field(o, "code"), - name: json_str_field(o, "name"), - native_name: json_opt_str_field(o, "nativeName"), - bible_count: json_int_field(o, "bibleCount"), - }]; - i = i + 1; + fn getInt = (obj: Dict.t, key: string): int => + switch obj->Dict.get(key) { + | Some(Number(n)) => Float.toInt(n) + | _ => 0 + } + + fn getOptInt = (obj: Dict.t, key: string): option => + switch obj->Dict.get(key) { + | Some(Number(n)) => Some(Float.toInt(n)) + | _ => None + } + + /** Parse language list from API JSON response */ + fn parseLanguages = (json: JSON.t): Crawler.Parser.parseResult> => { + switch json { + | Array(arr) => + fn langs = arr->Array.filterMap(item => { + switch item { + | Object(obj) => + Some({ + code: getString(obj, "code"), + name: getString(obj, "name"), + nativeName: getOptString(obj, "nativeName"), + bibleCount: getInt(obj, "bibleCount"), + }) + | _ => None } + }) + Crawler.Parser.Parsed(langs) + | Object(obj) => + switch obj->Dict.get("data") { + | Some(Array(arr)) => + fn langs = arr->Array.filterMap(item => { + switch item { + | Object(o) => + Some({ + code: getString(o, "code"), + name: getString(o, "name"), + nativeName: getOptString(o, "nativeName"), + bibleCount: getInt(o, "bibleCount"), + }) + | _ => None + } + }) Crawler.Parser.Parsed(langs) + | _ => Crawler.Parser.NoMatch } - None => Crawler.Parser.NoMatch, + | _ => Crawler.Parser.NoMatch } } - pub fn parse_bibles(json: Json) -> Crawler.Parser.ParseResult<[Types.BibleEntry]> { - match obj_array(json, "data") { - Some(objs) => { - let bibles = []; - let i = 0; - while i < len(objs) { - let o = objs[i]; - bibles = bibles ++ [Types.BibleEntry { - id: json_str_field(o, "id"), - title: json_str_field(o, "title"), - language_code: json_str_field(o, "languageCode"), - year: json_opt_int_field(o, "year"), - copyright: json_opt_str_field(o, "copyright"), - }]; - i = i + 1; + /** Parse Bible entries from API JSON response */ + fn parseBibles = (json: JSON.t): Crawler.Parser.parseResult> => { + fn parseArr = arr => + arr->Array.filterMap(item => { + switch item { + | Object(obj) => + Some({ + id: getString(obj, "id"), + title: getString(obj, "title"), + languageCode: getString(obj, "languageCode"), + year: getOptInt(obj, "year"), + copyright: getOptString(obj, "copyright"), + }) + | _ => None } - Crawler.Parser.Parsed(bibles) + }) + + switch json { + | Array(arr) => Crawler.Parser.Parsed(parseArr(arr)) + | Object(obj) => + switch obj->Dict.get("data") { + | Some(Array(arr)) => Crawler.Parser.Parsed(parseArr(arr)) + | _ => Crawler.Parser.NoMatch } - None => Crawler.Parser.NoMatch, + | _ => Crawler.Parser.NoMatch } } - pub fn parse_verses(json: Json, language_code: String) -> Crawler.Parser.ParseResult<[Lang1000.Verse.T]> { - match obj_array(json, "verses") { - Some(objs) => { - let verses = []; - let i = 0; - while i < len(objs) { - let o = objs[i]; - let text = json_str_field(o, "text"); - if len(text) > 0 { - verses = verses ++ [Lang1000.Verse.make( - Lang1000.Verse.make_reference( - json_str_field(o, "book"), - json_int_field(o, "chapter"), - json_int_field(o, "verse")), - text, language_code)]; + /** Parse verse text from API chapter response */ + fn parseVerses = (json: JSON.t, languageCode: string): Crawler.Parser.parseResult< + array, + > => { + fn parseVerseArr = arr => + arr->Array.filterMap(item => { + switch item { + | Object(obj) => + fn book = getString(obj, "book") + fn chapter = getInt(obj, "chapter") + fn verseNum = getInt(obj, "verse") + fn text = getString(obj, "text") + if String.length(text) > 0 { + Some( + Lang1000.Verse.make( + ~reference=Lang1000.Verse.makeReference(~book, ~chapter, ~verse=verseNum), + ~text, + ~language=languageCode, + ), + ) + } else { + None } - i = i + 1; + | _ => None } - Crawler.Parser.Parsed(verses) + }) + + switch json { + | Array(arr) => Crawler.Parser.Parsed(parseVerseArr(arr)) + | Object(obj) => + switch obj->Dict.get("verses") { + | Some(Array(arr)) => Crawler.Parser.Parsed(parseVerseArr(arr)) + | _ => Crawler.Parser.NoMatch } - None => Crawler.Parser.NoMatch, + | _ => Crawler.Parser.NoMatch } } } -module Crawler_ { - use Crawler.Types; - - pub type T = { - rate_limiter: Crawler.RateLimiter.T, - mut state: Crawler.Types.CrawlerState, +module Crawler = { + struct t { { + rateLimiter: Crawler.RateLimiter.t, + mutable state: crawlerState, } - pub fn make() -> T { - T { rate_limiter: Crawler.RateLimiter.make(Config.rate_limit_ms), state: Crawler.Types.Idle } + fn make = () => { + rateLimiter: Crawler.RateLimiter.make(~delayMs=Config.rateLimitMs, ()), + state: Idle, } - pub fn fetch_languages(crawler: T) -> Effect[Async] Crawler.Types.CrawlResult<[Types.LanguageInfo]> { - crawler.state = Crawler.Types.Crawling("languages"); - let resp = await Http.get_json(Endpoints.languages(), None); - crawler.state = Crawler.Types.Idle; - match resp { - Ok(json) => { - match Parser.parse_languages(json) { - Crawler.Parser.Parsed(langs) => Crawler.Types.Success(langs), - _ => Crawler.Types.Failure("Failed to parse language list"), - } + /** Discover available languages */ + fn fetchLanguages = async (crawler: t): crawlResult> => { + crawler.state = Crawling("languages") + fn resp = await Http.getJson(Endpoints.languages(), ()) + crawler.state = Idle + switch resp { + | Ok(json) => + switch Parser.parseLanguages(json) { + | Crawler.Parser.Parsed(langs) => Success(langs) + | _ => Failure("Failed to parse language list") } - Err(Http.HttpError(code, msg)) => Crawler.Types.Failure("HTTP " ++ show(code) ++ ": " ++ msg), - Err(_) => Crawler.Types.Failure("Request failed"), + | Error(Http.HttpError(code, msg)) => Failure(`HTTP ${Int.toString(code)}: ${msg}`) + | Error(_) => Failure("Request failed") } } - pub fn fetch_bibles(crawler: T, lang_code: String) -> Effect[Async] Crawler.Types.CrawlResult<[Types.BibleEntry]> { - let resp = await Http.get_with_rate_limit(Endpoints.bibles(lang_code), None, crawler.rate_limiter); - match resp { - Ok(r) => { - match Parser.parse_bibles(json_parse_exn(r.body)) { - Crawler.Parser.Parsed(bibles) => Crawler.Types.Success(bibles), - _ => Crawler.Types.Failure("Failed to parse bibles"), - } + /** Fetch Bibles available for a language */ + fn fetchBibles = async ( + crawler: t, + langCode: string, + ): crawlResult> => { + fn resp = await Http.getWithRateLimit( + Endpoints.bibles(langCode), + ~rateLimiter=crawler.rateLimiter, + (), + ) + switch resp { + | Ok({body}) => + switch Parser.parseBibles(JSON.parseExn(body)) { + | Crawler.Parser.Parsed(bibles) => Success(bibles) + | _ => Failure("Failed to parse bibles") } - Err(Http.HttpError(code, msg)) => Crawler.Types.Failure("HTTP " ++ show(code) ++ ": " ++ msg), - Err(_) => Crawler.Types.Failure("Request failed"), + | Error(Http.HttpError(code, msg)) => Failure(`HTTP ${Int.toString(code)}: ${msg}`) + | Error(_) => Failure("Request failed") } } - pub fn fetch_chapter(crawler: T, bible_id: String, book: String, chapter: Int, - language_code: String) -> Effect[Async] Crawler.Types.CrawlResult<[Lang1000.Verse.T]> { - let resp = await Http.get_with_rate_limit(Endpoints.text(bible_id, book, chapter), None, crawler.rate_limiter); - match resp { - Ok(r) => { - match Parser.parse_verses(json_parse_exn(r.body), language_code) { - Crawler.Parser.Parsed(verses) => Crawler.Types.Success(verses), - _ => Crawler.Types.Failure("Failed to parse verses"), - } + /** Fetch chapter text for a specific Bible */ + fn fetchChapter = async ( + crawler: t, + bibleId: string, + book: string, + chapter: int, + languageCode: string, + ): crawlResult> => { + fn resp = await Http.getWithRateLimit( + Endpoints.text(bibleId, book, chapter), + ~rateLimiter=crawler.rateLimiter, + (), + ) + switch resp { + | Ok({body}) => + switch Parser.parseVerses(JSON.parseExn(body), languageCode) { + | Crawler.Parser.Parsed(verses) => Success(verses) + | _ => Failure("Failed to parse verses") } - Err(Http.HttpError(code, msg)) => Crawler.Types.Failure("HTTP " ++ show(code) ++ ": " ++ msg), - Err(_) => Crawler.Types.Failure("Request failed"), + | Error(Http.HttpError(code, msg)) => Failure(`HTTP ${Int.toString(code)}: ${msg}`) + | Error(_) => Failure("Request failed") } } } + diff --git a/lol/src/crawlers/PngScriptures.affine b/lol/src/crawlers/PngScriptures.affine index d6a7c80ab..70cc4ac6b 100644 --- a/lol/src/crawlers/PngScriptures.affine +++ b/lol/src/crawlers/PngScriptures.affine @@ -1,136 +1,200 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// PNG Scriptures crawler. AffineScript port of PngScriptures.res. +// Ported via Harvard Engine (Semantic pass) module PngScriptures; -use Crawler; -use Http; -use Lang1000; +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors -extern fn str_split(s: String, sep: String) -> [String] = "string" "split"; -extern fn str_trim(s: String) -> String = "string" "trim"; -extern fn str_starts_with(s: String, p: String) -> Bool = "string" "startsWith"; -extern fn str_slice_to_end(s: String, start: Int) -> String = "string" "sliceToEnd"; -extern fn str_slice(s: String, start: Int, end: Int) -> String = "string" "slice"; -extern fn str_index_of(s: String, needle: String) -> Int = "string" "indexOf"; -extern fn str_replace_regex(s: String, pattern: String, repl: String) -> String = "string" "replaceRegExp"; -extern fn str_to_int(s: String) -> Option = "string" "toInt"; +/** + * PNG Scriptures Crawler + * + * Crawler for pngscriptures.org - Papua New Guinea Bible translations + * in numerous Papuan and Austronesian languages. + * Downloads ZIP archives and parses USFM/HTML content. + */ -module Config { - pub let base_url = "https://pngscriptures.org"; - pub let download_url = "https://pngscriptures.org/download"; - pub let rate_limit_ms = 2000; +open Crawler.Types - pub type Format = | Zip | Pdf | Epub | Html +module Config = { + fn baseUrl = "https://pngscriptures.org" + fn downloadUrl = "https://pngscriptures.org/download" + fn rateLimitMs = 2000 - pub fn format_to_string(f: Format) -> String { - match f { Zip => "zip", Pdf => "pdf", Epub => "epub", Html => "html" } - } + struct format { + | Zip + | Pdf + | Epub + | Html + + fn formatToString = format => + switch format { + | Zip => "zip" + | Pdf => "pdf" + | Epub => "epub" + | Html => "html" + } } -module Endpoints { - pub fn languages() -> String { Config.base_url ++ "/languages" } - pub fn language(code: String) -> String { Config.base_url ++ "/lng/" ++ code } - pub fn download(code: String, format: Config.Format) -> String { - Config.download_url ++ "/" ++ code ++ "/" ++ Config.format_to_string(format) - } +module Endpoints = { + fn languages = () => `${Config.baseUrl}/languages` + fn language = langCode => `${Config.baseUrl}/lng/${langCode}` + fn download = (langCode, format) => + `${Config.downloadUrl}/${langCode}/${Config.formatToString(format)}` } -module Types { - pub type PngLanguage = { - code: String, - name: String, - alternate_name: Option, - region: String, - population: Option, - has_new_testament: Bool, - has_old_testament: Bool, +module Types = { + struct pngLanguage { { + code: string, + name: string, + alternateName: option, + region: string, + population: option, + hasNewTestament: bool, + hasOldTestament: bool, } - pub type DownloadInfo = { - language: PngLanguage, - format: Config.Format, - size_bytes: Option, - last_updated: Option, + struct downloadInfo { { + language: pngLanguage, + format: Config.format, + sizeBytes: option, + lastUpdated: option, } } -module Parser { - use Types; - - pub fn parse_language_list(html: String) -> Crawler.Parser.ParseResult<[Types.PngLanguage]> { - let languages = []; - let segs = str_split(html, "href=\"/lng/"); - let i = 1; - while i < len(segs) { - let q = str_index_of(segs[i], "\""); - if q > 0 { - let code = str_slice(segs[i], 0, q); - if len(code) == 3 { - languages = languages ++ [Types.PngLanguage { - code: code, name: code, alternate_name: None, - region: "Papua New Guinea", population: None, - has_new_testament: true, has_old_testament: false, - }]; +module Parser = { + open Types + + /** Parse language listing from HTML page using regex extraction */ + fn parseLanguageList = (html: string): Crawler.Parser.parseResult> => { + // Extract language entries from the listing page + fn langRegex = %re( + "/href=\"\/lng\/([a-z]{3})\"[^>]*>([^<]+)<.*?(?:region:\s*([^<,]+))?/gs" + ) + fn languages = [] + fn _ = html->String.replaceRegExp(langRegex, (match_, _offset, _str) => { + // Simplified extraction - real implementation would be more robust + fn parts = match_->String.split("\"") + if Array.length(parts) >= 2 { + fn code = Array.getUnsafe(parts, 1)->String.replaceRegExp(%re("/.*\//"), "") + if String.length(code) == 3 { + ignore( + Array.concat( + languages, + [ + { + code, + name: code, + alternateName: None, + region: "Papua New Guinea", + population: None, + hasNewTestament: true, + hasOldTestament: false, + }, + ], + ), + ) } } - i = i + 1; - } - if len(languages) > 0 { + match_ + }) + + if Array.length(languages) > 0 { Crawler.Parser.Parsed(languages) } else { Crawler.Parser.NoMatch } } - pub fn parse_usfm(usfm: String, language_code: String) -> [Lang1000.Verse.T] { - let verses = []; - let current_book = ""; - let current_chapter = 0; - let lines = str_split(usfm, "\n"); - let i = 0; - while i < len(lines) { - let trimmed = str_trim(lines[i]); - if str_starts_with(trimmed, "\\id ") { - current_book = str_split(str_slice_to_end(trimmed, 4), " ")[0]; + /** Parse USFM (Unified Standard Format Markers) text into verses */ + fn parseUsfm = (usfm: string, languageCode: string): array => { + fn verses: ref> = ref([]) + fn currentBook = ref("") + fn currentChapter = ref(0) + + fn lines = usfm->String.split("\n") + lines->Array.forEach(line => { + fn trimmed = String.trim(line) + // Book marker: \id GEN + if String.startsWith(trimmed, "\\id ") { + currentBook := String.sliceToEnd(trimmed, ~start=4)->String.split(" ")->Array.getUnsafe(0) } - if str_starts_with(trimmed, "\\c ") { - current_chapter = match str_to_int(str_trim(str_slice_to_end(trimmed, 3))) { Some(n) => n, None => 0 }; + // Chapter marker: \c 1 + if String.startsWith(trimmed, "\\c ") { + currentChapter := + String.sliceToEnd(trimmed, ~start=3) + ->String.trim + ->Int.fromString + ->Option.getOr(0) } - if str_starts_with(trimmed, "\\v ") { - let rest = str_slice_to_end(trimmed, 3); - let space_idx = str_index_of(rest, " "); - if space_idx > 0 { - let verse_num = match str_to_int(str_slice(rest, 0, space_idx)) { Some(n) => n, None => 0 }; - let text = str_trim(str_replace_regex(str_slice_to_end(rest, space_idx + 1), "\\\\[a-z]+\\s?", "")); - if verse_num > 0 && len(text) > 0 { - verses = verses ++ [Lang1000.Verse.make( - Lang1000.Verse.make_reference(current_book, current_chapter, verse_num), - text, language_code)]; + // Verse marker: \v 1 In the beginning... + if String.startsWith(trimmed, "\\v ") { + fn rest = String.sliceToEnd(trimmed, ~start=3) + fn spaceIdx = String.indexOf(rest, " ") + if spaceIdx > 0 { + fn verseNum = String.slice(rest, ~start=0, ~end=spaceIdx)->Int.fromString->Option.getOr(0) + fn text = + String.sliceToEnd(rest, ~start=spaceIdx + 1) + ->String.replaceRegExp(%re("/\\\\[a-z]+\s?/g"), "") + ->String.trim + if verseNum > 0 && String.length(text) > 0 { + verses := + Array.concat( + verses.contents, + [ + Lang1000.Verse.make( + ~reference=Lang1000.Verse.makeReference( + ~book=currentBook.contents, + ~chapter=currentChapter.contents, + ~verse=verseNum, + ), + ~text, + ~language=languageCode, + ), + ], + ) } } } - i = i + 1; - } - verses + }) + + verses.contents } - pub fn parse_html_book(html: String, language_code: String) -> Crawler.Parser.ParseResult<[Lang1000.Verse.T]> { - let verses = []; - let blocks = str_split(html, "class=\"verse\""); - let i = 1; - while i < len(blocks) { - let cleaned = str_trim(str_replace_regex(blocks[i], "<[^>]+>", "")); - if len(cleaned) > 0 { - verses = verses ++ [Lang1000.Verse.make( - Lang1000.Verse.make_reference("UNK", 1, len(verses) + 1), - cleaned, language_code)]; + /** Parse individual book HTML file extracting verse text */ + fn parseHtmlBook = (html: string, languageCode: string): Crawler.Parser.parseResult< + array, + > => { + // Extract verse text from HTML structure + fn verseRegex = %re("/class=\"verse\"[^>]*data-verse=\"(\d+)\"[^>]*>(.*?)<\/[^>]+>/gs") + fn verses = [] + fn _ = html->String.replaceRegExp(verseRegex, (match_, _offset, _str) => { + fn cleaned = + match_ + ->String.replaceRegExp(%re("/<[^>]+>/g"), "") + ->String.trim + if String.length(cleaned) > 0 { + ignore( + Array.concat( + verses, + [ + Lang1000.Verse.make( + ~reference=Lang1000.Verse.makeReference( + ~book="UNK", + ~chapter=1, + ~verse=Array.length(verses) + 1, + ), + ~text=cleaned, + ~language=languageCode, + ), + ], + ), + ) } - i = i + 1; - } - if len(verses) > 0 { + match_ + }) + + if Array.length(verses) > 0 { Crawler.Parser.Parsed(verses) } else { Crawler.Parser.NoMatch @@ -138,63 +202,76 @@ module Parser { } } -module Crawler_ { - use Crawler.Types; - - pub type T = { - rate_limiter: Crawler.RateLimiter.T, - mut state: Crawler.Types.CrawlerState, - download_dir: String, +module Crawler = { + struct t { { + rateLimiter: Crawler.RateLimiter.t, + mutable state: crawlerState, + downloadDir: string, } - pub fn make(download_dir: String) -> T { - T { - rate_limiter: Crawler.RateLimiter.make(Config.rate_limit_ms), - state: Crawler.Types.Idle, - download_dir: download_dir, - } + fn make = (~downloadDir="./downloads/png", ()) => { + rateLimiter: Crawler.RateLimiter.make(~delayMs=Config.rateLimitMs, ()), + state: Idle, + downloadDir, } - pub fn fetch_languages(crawler: T) -> Effect[Async] Crawler.Types.CrawlResult<[Types.PngLanguage]> { - crawler.state = Crawler.Types.Crawling("languages"); - let resp = await Http.get_with_rate_limit(Endpoints.languages(), None, crawler.rate_limiter); - crawler.state = Crawler.Types.Idle; - match resp { - Ok(r) => { - match Parser.parse_language_list(r.body) { - Crawler.Parser.Parsed(langs) => Crawler.Types.Success(langs), - _ => Crawler.Types.Failure("Failed to parse language list"), - } + fn fetchLanguages = async (crawler: t): crawlResult> => { + crawler.state = Crawling("languages") + fn resp = await Http.getWithRateLimit( + Endpoints.languages(), + ~rateLimiter=crawler.rateLimiter, + (), + ) + crawler.state = Idle + switch resp { + | Ok({body}) => + switch Parser.parseLanguageList(body) { + | Crawler.Parser.Parsed(langs) => Success(langs) + | _ => Failure("Failed to parse language list") } - Err(Http.HttpError(code, msg)) => Crawler.Types.Failure("HTTP " ++ show(code) ++ ": " ++ msg), - Err(_) => Crawler.Types.Failure("Request failed"), + | Error(Http.HttpError(code, msg)) => Failure(`HTTP ${Int.toString(code)}: ${msg}`) + | Error(_) => Failure("Request failed") } } - pub fn download_translation(crawler: T, lang_code: String, - format: Config.Format) -> Effect[Async] Crawler.Types.CrawlResult { - let resp = await Http.get_with_rate_limit(Endpoints.download(lang_code, format), None, crawler.rate_limiter); - match resp { - Ok(r) => { - let path = crawler.download_dir ++ "/" ++ lang_code ++ "." ++ Config.format_to_string(format); - Crawler.Types.Success(path ++ " (" ++ show(len(r.body)) ++ " bytes)") - } - Err(Http.HttpError(code, msg)) => Crawler.Types.Failure("HTTP " ++ show(code) ++ ": " ++ msg), - Err(_) => Crawler.Types.Failure("Download failed"), + fn downloadTranslation = async ( + crawler: t, + langCode: string, + format: Config.format, + ): crawlResult => { + fn resp = await Http.getWithRateLimit( + Endpoints.download(langCode, format), + ~rateLimiter=crawler.rateLimiter, + (), + ) + switch resp { + | Ok({body}) => + fn path = `${crawler.downloadDir}/${langCode}.${Config.formatToString(format)}` + Success(path ++ " (" ++ Int.toString(String.length(body)) ++ " bytes)") + | Error(Http.HttpError(code, msg)) => Failure(`HTTP ${Int.toString(code)}: ${msg}`) + | Error(_) => Failure("Download failed") } } - pub fn fetch_and_parse(crawler: T, lang_code: String) -> Effect[Async] Crawler.Types.CrawlResult<[Lang1000.Verse.T]> { - let resp = await Http.get_with_rate_limit(Endpoints.language(lang_code), None, crawler.rate_limiter); - match resp { - Ok(r) => { - match Parser.parse_html_book(r.body, lang_code) { - Crawler.Parser.Parsed(verses) => Crawler.Types.Success(verses), - _ => Crawler.Types.Failure("No verses extracted from HTML"), - } + fn fetchAndParse = async ( + crawler: t, + langCode: string, + ): crawlResult> => { + // Fetch HTML version for parsing + fn resp = await Http.getWithRateLimit( + Endpoints.language(langCode), + ~rateLimiter=crawler.rateLimiter, + (), + ) + switch resp { + | Ok({body}) => + switch Parser.parseHtmlBook(body, langCode) { + | Crawler.Parser.Parsed(verses) => Success(verses) + | _ => Failure("No verses extracted from HTML") } - Err(Http.HttpError(code, msg)) => Crawler.Types.Failure("HTTP " ++ show(code) ++ ": " ++ msg), - Err(_) => Crawler.Types.Failure("Request failed"), + | Error(Http.HttpError(code, msg)) => Failure(`HTTP ${Int.toString(code)}: ${msg}`) + | Error(_) => Failure("Request failed") } } } + diff --git a/lol/src/cyc/OpenCyc.affine b/lol/src/cyc/OpenCyc.affine index 3b39359a2..315dd926e 100644 --- a/lol/src/cyc/OpenCyc.affine +++ b/lol/src/cyc/OpenCyc.affine @@ -1,216 +1,247 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// OpenCyc integration: semantic grounding for language concepts. -// AffineScript port of OpenCyc.res. +// Ported via Harvard Engine (Semantic pass) module OpenCyc; -use Lang1000; +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors -extern fn str_lower(s: String) -> String = "string" "toLowerCase"; -extern fn promise_resolve(v: a) -> Promise = "Promise" "resolve"; +/** + * OpenCyc Integration + * + * Provides semantic grounding for language concepts using the OpenCyc + * knowledge base. This enables common-sense reasoning about languages, + * scripts, regions, and linguistic properties. + */ -module Config { - pub let default_endpoint = "http://localhost:3602"; - pub let connection_timeout = 5000; +module Config = { + fn defaultEndpoint = "http://localhost:3602" + fn connectionTimeout = 5000 - pub type Credentials = { endpoint: String, timeout: Int } + struct credentials { { + endpoint: string, + timeout: int, + } - pub fn default() -> Credentials { - Credentials { endpoint: default_endpoint, timeout: connection_timeout } + fn default = { + endpoint: defaultEndpoint, + timeout: connectionTimeout, } } -module Concepts { - pub let human_language = "#$HumanLanguage"; - pub let writing_script = "#$WritingScript"; - pub let geographical_region = "#$GeographicalRegion"; - pub let linguistic_property = "#$LinguisticProperty"; - pub let language_family = "#$LanguageFamily"; - pub let spoken_in = "#$languageSpokenInRegion"; - pub let written_in = "#$languageWrittenInScript"; - pub let sub_language_of = "#$subLanguageOf"; - pub let word_order = "#$WordOrder"; - pub let phonological_inventory = "#$PhonologicalInventory"; - pub let morphological_type = "#$MorphologicalType"; +module Concepts = { + // Core Cyc concepts for linguistics + fn humanLanguage = "#$HumanLanguage" + fn writingScript = "#$WritingScript" + fn geographicalRegion = "#$GeographicalRegion" + fn linguisticProperty = "#$LinguisticProperty" + fn languageFamily = "#$LanguageFamily" + fn spokenIn = "#$languageSpokenInRegion" + fn writtenIn = "#$languageWrittenInScript" + fn subLanguageOf = "#$subLanguageOf" + + // WALS typological features + fn wordOrder = "#$WordOrder" + fn phonologicalInventory = "#$PhonologicalInventory" + fn morphologicalType = "#$MorphologicalType" } -module Types { - pub type CycConstant = String +module Types = { + struct cycConstant { string // e.g., "#$English-HumanLanguage" - pub type CycFormula = - | Atom(CycConstant) - | List([CycFormula]) - | Variable(String) + struct cycFormula { + | Atom(cycConstant) + | List(array) + | Variable(string) - pub type QueryResult = - | Success([Dict]) - | Failure(String) + struct queryResult { + | Success(array>) + | Failure(string) | Timeout - pub type ConnectionState = + struct connectionState { | Connected | Disconnected | Connecting - | ConnError(String) + | Error(string) - pub type LanguageMapping = { - iso639_3: String, - cyc_constant: CycConstant, - name: String, - family: Option, - region: Option, - script: Option, + struct languageMapping { { + iso639_3: string, + cycConstant: cycConstant, + name: string, + family: option, + region: option, + script: option, } } -module Client { - use Types; +module Client = { + open Types - pub type T = { - config: Config.Credentials, - mut state: Types.ConnectionState, + struct t { { + config: Config.credentials, + mutable state: connectionState, } - pub fn make(endpoint: Option, timeout: Option) -> T { - T { - config: Config.Credentials { - endpoint: match endpoint { Some(e) => e, None => Config.default_endpoint }, - timeout: match timeout { Some(t) => t, None => Config.connection_timeout }, - }, - state: Types.Disconnected, - } + fn make = (~endpoint=?, ~timeout=?, ()) => { + config: { + endpoint: endpoint->Option.getOr(Config.defaultEndpoint), + timeout: timeout->Option.getOr(Config.connectionTimeout), + }, + state: Disconnected, } - // TODO: implement actual connection to OpenCyc server. - pub fn connect(_client: T) -> Promise> { - promise_resolve(Err("OpenCyc connection not implemented")) + fn connect = (_client: t): promise> => { + // TODO: Implement actual connection to OpenCyc server + Promise.resolve(Error("OpenCyc connection not implemented")) } - pub fn disconnect(client: T) -> Unit { - client.state = Types.Disconnected + fn disconnect = (client: t): unit => { + client.state = Disconnected } - pub fn is_connected(client: T) -> Bool { - match client.state { Connected => true, _ => false } + fn isConnected = (client: t): bool => { + switch client.state { + | Connected => true + | _ => false + } } } -module Query { - use Types; +module Query = { + open Types - pub fn language_query(iso639_3: String) -> String { - "(#$isa ?lang #$HumanLanguage)\n (#$iso639-3Code ?lang \"" ++ iso639_3 ++ "\")" + // Build a CycL query for language information + fn languageQuery = (iso639_3: string): string => { + `(#$isa ?lang #$HumanLanguage) + (#$iso639-3Code ?lang "${iso639_3}")` } - pub fn languages_in_region(region: Types.CycConstant) -> String { - "(#$isa ?lang #$HumanLanguage)\n (#$languageSpokenInRegion ?lang " ++ region ++ ")" + // Query for languages in a region + fn languagesInRegion = (region: cycConstant): string => { + `(#$isa ?lang #$HumanLanguage) + (#$languageSpokenInRegion ?lang ${region})` } - pub fn languages_with_script(script: Types.CycConstant) -> String { - "(#$isa ?lang #$HumanLanguage)\n (#$languageWrittenInScript ?lang " ++ script ++ ")" + // Query for languages using a script + fn languagesWithScript = (script: cycConstant): string => { + `(#$isa ?lang #$HumanLanguage) + (#$languageWrittenInScript ?lang ${script})` } - pub fn language_family_query(language: Types.CycConstant) -> String { - "(#$subLanguageOf " ++ language ++ " ?family)" + // Query for language family relationships + fn languageFamilyQuery = (language: cycConstant): string => { + `(#$subLanguageOf ${language} ?family)` } - // TODO: implement actual query execution. - pub fn execute(_client: Client.T, _query: String) -> Promise { - promise_resolve(Types.Failure("Query execution not implemented")) + fn execute = (_client: Client.t, _query: string): promise => { + // TODO: Implement actual query execution + Promise.resolve(Failure("Query execution not implemented")) } } -module LanguageOntology { - use Types; - - pub fn iso639_to_cyc() -> Dict { - let d = dict_empty(); - dict_set(d, "eng", "#$English-HumanLanguage"); - dict_set(d, "deu", "#$German-HumanLanguage"); - dict_set(d, "fra", "#$French-HumanLanguage"); - dict_set(d, "spa", "#$Spanish-HumanLanguage"); - dict_set(d, "por", "#$Portuguese-HumanLanguage"); - dict_set(d, "ita", "#$Italian-HumanLanguage"); - dict_set(d, "rus", "#$Russian-HumanLanguage"); - dict_set(d, "zho", "#$Chinese-HumanLanguage"); - dict_set(d, "jpn", "#$Japanese-HumanLanguage"); - dict_set(d, "kor", "#$Korean-HumanLanguage"); - dict_set(d, "ara", "#$Arabic-HumanLanguage"); - dict_set(d, "heb", "#$Hebrew-HumanLanguage"); - dict_set(d, "ell", "#$Greek-HumanLanguage"); - dict_set(d, "lat", "#$Latin-HumanLanguage"); - dict_set(d, "san", "#$Sanskrit-HumanLanguage"); +module LanguageOntology = { + open Types + + // Map ISO 639-3 codes to Cyc constants + fn iso639ToCyc: Dict.t = { + fn d = Dict.make() + Dict.set(d, "eng", "#$English-HumanLanguage") + Dict.set(d, "deu", "#$German-HumanLanguage") + Dict.set(d, "fra", "#$French-HumanLanguage") + Dict.set(d, "spa", "#$Spanish-HumanLanguage") + Dict.set(d, "por", "#$Portuguese-HumanLanguage") + Dict.set(d, "ita", "#$Italian-HumanLanguage") + Dict.set(d, "rus", "#$Russian-HumanLanguage") + Dict.set(d, "zho", "#$Chinese-HumanLanguage") + Dict.set(d, "jpn", "#$Japanese-HumanLanguage") + Dict.set(d, "kor", "#$Korean-HumanLanguage") + Dict.set(d, "ara", "#$Arabic-HumanLanguage") + Dict.set(d, "heb", "#$Hebrew-HumanLanguage") + Dict.set(d, "ell", "#$Greek-HumanLanguage") + Dict.set(d, "lat", "#$Latin-HumanLanguage") + Dict.set(d, "san", "#$Sanskrit-HumanLanguage") d } - pub fn language_families() -> Dict { - let d = dict_empty(); - dict_set(d, "indo-european", "#$IndoEuropeanLanguageFamily"); - dict_set(d, "sino-tibetan", "#$SinoTibetanLanguageFamily"); - dict_set(d, "afroasiatic", "#$AfroAsiaticLanguageFamily"); - dict_set(d, "austronesian", "#$AustronesianLanguageFamily"); - dict_set(d, "niger-congo", "#$NigerCongoLanguageFamily"); - dict_set(d, "dravidian", "#$DravidianLanguageFamily"); - dict_set(d, "uralic", "#$UralicLanguageFamily"); - dict_set(d, "altaic", "#$AltaicLanguageFamily"); + // Major language families + fn languageFamilies: Dict.t = { + fn d = Dict.make() + Dict.set(d, "indo-european", "#$IndoEuropeanLanguageFamily") + Dict.set(d, "sino-tibetan", "#$SinoTibetanLanguageFamily") + Dict.set(d, "afroasiatic", "#$AfroAsiaticLanguageFamily") + Dict.set(d, "austronesian", "#$AustronesianLanguageFamily") + Dict.set(d, "niger-congo", "#$NigerCongoLanguageFamily") + Dict.set(d, "dravidian", "#$DravidianLanguageFamily") + Dict.set(d, "uralic", "#$UralicLanguageFamily") + Dict.set(d, "altaic", "#$AltaicLanguageFamily") d } - pub fn writing_scripts() -> Dict { - let d = dict_empty(); - dict_set(d, "latin", "#$LatinAlphabet"); - dict_set(d, "cyrillic", "#$CyrillicAlphabet"); - dict_set(d, "greek", "#$GreekAlphabet"); - dict_set(d, "arabic", "#$ArabicScript"); - dict_set(d, "hebrew", "#$HebrewAlphabet"); - dict_set(d, "devanagari", "#$DevanagariScript"); - dict_set(d, "chinese", "#$ChineseCharacters"); - dict_set(d, "japanese", "#$JapaneseWritingSystem"); - dict_set(d, "korean", "#$HangulAlphabet"); + // Writing scripts + fn writingScripts: Dict.t = { + fn d = Dict.make() + Dict.set(d, "latin", "#$LatinAlphabet") + Dict.set(d, "cyrillic", "#$CyrillicAlphabet") + Dict.set(d, "greek", "#$GreekAlphabet") + Dict.set(d, "arabic", "#$ArabicScript") + Dict.set(d, "hebrew", "#$HebrewAlphabet") + Dict.set(d, "devanagari", "#$DevanagariScript") + Dict.set(d, "chinese", "#$ChineseCharacters") + Dict.set(d, "japanese", "#$JapaneseWritingSystem") + Dict.set(d, "korean", "#$HangulAlphabet") d } - pub fn get_cyc_constant(iso639_3: String) -> Option { - dict_get(iso639_to_cyc(), iso639_3) + fn getCycConstant = (iso639_3: string): option => { + Dict.get(iso639ToCyc, iso639_3) } - pub fn get_language_family(family_name: String) -> Option { - dict_get(language_families(), str_lower(family_name)) + fn getLanguageFamily = (familyName: string): option => { + Dict.get(languageFamilies, String.toLowerCase(familyName)) } - pub fn get_script(script_name: String) -> Option { - dict_get(writing_scripts(), str_lower(script_name)) + fn getScript = (scriptName: string): option => { + Dict.get(writingScripts, String.toLowerCase(scriptName)) } } -module Reasoning { - use Types; +module Reasoning = { + open Types - pub fn is_in_family(_client: Client.T, _language: Types.CycConstant, _family: Types.CycConstant) -> Promise { - promise_resolve(false) + // Check if a language is in a specific family + fn isInFamily = (_client: Client.t, _language: cycConstant, _family: cycConstant): promise => { + // TODO: Query Cyc for family membership + Promise.resolve(false) } - pub fn languages_in_region(_client: Client.T, _region: Types.CycConstant) -> Promise<[Types.LanguageMapping]> { - promise_resolve([]) + // Get all languages spoken in a geographic region + fn languagesInRegion = (_client: Client.t, _region: cycConstant): promise> => { + Promise.resolve([]) } - pub fn related_languages(_client: Client.T, _language: Types.CycConstant) -> Promise<[Types.CycConstant]> { - promise_resolve([]) + // Find related languages (same family) + fn relatedLanguages = (_client: Client.t, _language: cycConstant): promise> => { + Promise.resolve([]) } - pub fn scripts_compatible(_script1: Types.CycConstant, _script2: Types.CycConstant) -> Bool { + // Check if two scripts are compatible (can represent the same language) + fn scriptsCompatible = (_script1: cycConstant, _script2: cycConstant): bool => { + // TODO: Implement compatibility check false } } -module Sync { - pub fn sync_languages(_client: Client.T, _languages: [Lang1000.Language.T]) -> Promise { - promise_resolve(0) +module Sync = { + // Synchronize local language data with OpenCyc + fn syncLanguages = (_client: Client.t, _languages: array): promise => { + // TODO: Implement synchronization + Promise.resolve(0) } - pub fn update_mappings(_client: Client.T) -> Promise> { - promise_resolve(Err("Not implemented")) + // Update local mappings from Cyc + fn updateMappings = (_client: Client.t): promise> => { + Promise.resolve(Error("Not implemented")) } } + diff --git a/lol/src/utils/Http.affine b/lol/src/utils/Http.affine index b38f1c08c..2c911c524 100644 --- a/lol/src/utils/Http.affine +++ b/lol/src/utils/Http.affine @@ -1,131 +1,149 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// HTTP fetch module: Deno fetch() with rate limiting, retry logic and -// error handling for crawler use. AffineScript port of Http.res. +// Ported via Harvard Engine (Semantic pass) module Http; -use Crawler; - -module Fetch { - extern type Response; - extern fn fetch(url: String, init: Json) -> Promise = "global" "fetch"; - extern fn fetch_simple(url: String) -> Promise = "global" "fetch"; - extern fn status(r: Response) -> Int = "fetch" "status"; - extern fn ok(r: Response) -> Bool = "fetch" "ok"; - extern fn status_text(r: Response) -> String = "fetch" "statusText"; - extern fn text(r: Response) -> Promise = "fetch" "text"; - extern fn json(r: Response) -> Promise = "fetch" "json"; - extern fn get_header(r: Response, name: String) -> Option = "fetch" "headers.get"; -} +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors + +/** + * HTTP Fetch Module + * + * ReScript bindings to Deno's native fetch() API with rate limiting, + * retry logic, and error handling for crawler use. + */ + +/** Deno/Web fetch API bindings */ +module Fetch = { + struct response -extern fn json_parse_exn(s: String) -> Json = "JSON" "parseExn"; -extern fn json_encode_object(d: Dict) -> Json = "JSON" "encodeObject"; -extern fn json_encode_string(s: String) -> Json = "JSON" "encodeString"; -extern fn set_timeout(cb: fn() -> Unit, ms: Int) -> Int = "global" "setTimeout"; -extern fn sleep_ms(ms: Int) -> Promise = "global" "sleep"; + @val external fetch: (string, {..}) => promise = "fetch" + @val external fetchSimple: string => promise = "fetch" + + @get external status: response => int = "status" + @get external ok: response => bool = "ok" + @get external statusText: response => string = "statusText" + @send external text: response => promise = "text" + @send external json: response => promise = "json" + @send external getHeader: (response, string) => option = "headers.get" +} -pub type HttpError = - | NetworkError(String) - | HttpError(Int, String) +struct httpError { + | NetworkError(string) + | HttpError(int, string) | TimeoutError - | ParseError(String) + | ParseError(string) -pub type HttpResponse = { - status: Int, - body: String, - headers: Dict, +struct httpResponse { { + status: int, + body: string, + headers: Dict.t, } -// Perform a GET request with optional headers. -pub fn get(url: String, headers: Option>) -> Effect[Async] Result { +/** Perform a GET request with optional headers */ +fn get = async (url: string, ~headers: option>=?, ()): result< + httpResponse, + httpError, +> => { try { - let resp = match headers { - Some(h) => { - let header_dict = dict_empty(); - let pairs = dict_entries(h); - let i = 0; - while i < len(pairs) { - let (k, v) = pairs[i]; - dict_set(header_dict, k, json_encode_string(v)); - i = i + 1; - } - await Fetch.fetch(url, json_object([ - ("method", json_string("GET")), - ("headers", json_encode_object(header_dict)), - ])) - } - None => await Fetch.fetch_simple(url), - }; - - let body = await Fetch.text(resp); - let status = Fetch.status(resp); + fn resp = switch headers { + | Some(h) => + fn headerDict = Dict.make() + h->Dict.toArray->Array.forEach(((k, v)) => { + headerDict->Dict.set(k, JSON.Encode.string(v)) + }) + await Fetch.fetch( + url, + {"method": "GET", "headers": JSON.Encode.object(headerDict)}, + ) + | None => await Fetch.fetchSimple(url) + } + + fn body = await Fetch.text(resp) + fn status = Fetch.status(resp) if Fetch.ok(resp) { - Ok(HttpResponse { status: status, body: body, headers: dict_empty() }) + Ok({status, body, headers: Dict.make()}) } else if status == 429 { - Err(HttpError(429, "Rate limited")) + Error(HttpError(429, "Rate limited")) } else if status == 404 { - Err(HttpError(404, "Not found")) + Error(HttpError(404, "Not found")) } else { - Err(HttpError(status, Fetch.status_text(resp))) + Error(HttpError(status, Fetch.statusText(resp))) + } + } catch { + | exn => + fn msg = switch exn { + | Exn.Error(e) => Exn.message(e)->Option.getOr("Unknown error") + | _ => "Unknown error" } - } catch e { - Err(NetworkError(exn_message_or(e, "Unknown error"))) + Error(NetworkError(msg)) } } -// Perform a GET request and parse the response as JSON. -pub fn get_json(url: String, headers: Option>) -> Effect[Async] Result { - match await get(url, headers) { - Ok(r) => { - try { - Ok(json_parse_exn(r.body)) - } catch _e { - Err(ParseError("Invalid JSON response")) - } +/** Perform a GET request and parse response as JSON */ +fn getJson = async (url: string, ~headers: option>=?, ()): result< + JSON.t, + httpError, +> => { + fn resp = await get(url, ~headers?, ()) + switch resp { + | Ok({body}) => + try { + Ok(JSON.parseExn(body)) + } catch { + | _ => Error(ParseError("Invalid JSON response")) } - Err(e) => Err(e), + | Error(e) => Error(e) } } -// Perform a GET request with rate limiter integration. -pub fn get_with_rate_limit(url: String, headers: Option>, - rate_limiter: Crawler.RateLimiter.T) -> Effect[Async] Result { - fn wait_for_limit() -> Effect[Async] Unit { - if Crawler.RateLimiter.can_proceed(rate_limiter) { - Crawler.RateLimiter.record_request(rate_limiter) +/** Perform a GET request with rate limiter integration */ +fn getWithRateLimit = async ( + url: string, + ~headers: option>=?, + ~rateLimiter: Crawler.RateLimiter.t, + (), +): result => { + // Wait until rate limit allows + fn rec waitForLimit = async () => { + if Crawler.RateLimiter.canProceed(rateLimiter) { + Crawler.RateLimiter.recordRequest(rateLimiter) } else { - await sleep_ms(rate_limiter.delay_ms); - await wait_for_limit() + await Promise.make((resolve, _) => { + fn _ = setTimeout(() => resolve(.), rateLimiter.delayMs) + }) + await waitForLimit() } } - await wait_for_limit(); - await get(url, headers) + await waitForLimit() + await get(url, ~headers?, ()) } -// Perform a GET request with retry logic. -pub fn get_with_retry(url: String, headers: Option>, - max_retries: Int, backoff: Crawler.RetryPolicy.T) -> Effect[Async] Result { - fn attempt(n: Int) -> Effect[Async] Result { - let resp = await get(url, headers); - match resp { - Ok(_) => resp, - Err(HttpError(429, _)) => { - if n < max_retries { - await sleep_ms(Crawler.RetryPolicy.calculate_delay(backoff, n + 1)); - await attempt(n + 1) - } else { resp } - } - Err(NetworkError(_)) => { - if n < max_retries { - await sleep_ms(Crawler.RetryPolicy.calculate_delay(backoff, n + 1)); - await attempt(n + 1) - } else { resp } - } - Err(_) => resp, +/** Perform a GET request with retry logic */ +fn getWithRetry = async ( + url: string, + ~headers: option>=?, + ~maxRetries=3, + ~backoff=Crawler.RetryPolicy.Exponential(1000, 2.0), + (), +): result => { + fn rec attempt = async (n: int) => { + fn resp = await get(url, ~headers?, ()) + switch resp { + | Ok(_) => resp + | Error(HttpError(429, _)) | Error(NetworkError(_)) if n < maxRetries => + fn delay = Crawler.RetryPolicy.calculateDelay(backoff, n + 1) + await Promise.make((resolve, _) => { + fn _ = setTimeout(() => resolve(.), delay) + }) + await attempt(n + 1) + | Error(_) => resp } } await attempt(0) } + +/** Deno setTimeout binding */ +@val external setTimeout: (unit => unit, int) => int = "setTimeout" + diff --git a/lol/src/utils/Iso639.affine b/lol/src/utils/Iso639.affine index 34f988cd5..e73fb39d7 100644 --- a/lol/src/utils/Iso639.affine +++ b/lol/src/utils/Iso639.affine @@ -1,144 +1,170 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// ISO 639 language code utilities (639-1/2b/2t/3). -// AffineScript port of Iso639.res. +// Ported via Harvard Engine (Semantic pass) module Iso639; -extern fn re_test(pattern: String, s: String) -> Bool = "regex" "test"; -extern fn str_lower(s: String) -> String = "string" "toLowerCase"; -extern fn str_trim(s: String) -> String = "string" "trim"; - -module Types { - pub type Iso639_1 = String - pub type Iso639_2b = String - pub type Iso639_2t = String - pub type Iso639_3 = String - - pub type CodeType = - | C_Iso639_1 - | C_Iso639_2b - | C_Iso639_2t - | C_Iso639_3 - - pub type LanguageScope = - | Individual - | Macrolanguage - | Special - - pub type LanguageType = - | Living - | Historical - | Extinct - | Ancient - | Constructed - - pub type LanguageEntry = { - iso639_3: Iso639_3, - iso639_2b: Option, - iso639_2t: Option, - iso639_1: Option, - scope: LanguageScope, - type_: LanguageType, - name: String, - comment: Option, +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors + +/** + * ISO 639 Language Code Utilities + * + * Handles ISO 639-1 (2-letter), ISO 639-2 (3-letter bibliographic/terminological), + * and ISO 639-3 (3-letter comprehensive) language codes. + */ + +module Types = { + struct iso639_1 { string // 2-letter code (en, de, fr) + struct iso639_2b { string // 3-letter bibliographic (ger, fre) + struct iso639_2t { string // 3-letter terminological (deu, fra) + struct iso639_3 { string // 3-letter comprehensive (eng, deu, fra) + + struct codeType { + | Iso639_1 + | Iso639_2b + | Iso639_2t + | Iso639_3 + + struct languageScope { + | Individual // I - Individual language + | Macrolanguage // M - Macrolanguage + | Special // S - Special (mis, mul, und, zxx) + + struct languageType { + | Living // L - Living language + | Historical // H - Historical language + | Extinct // E - Extinct language + | Ancient // A - Ancient language + | Constructed // C - Constructed language + + struct languageEntry { { + iso639_3: iso639_3, + iso639_2b: option, + iso639_2t: option, + iso639_1: option, + scope: languageScope, + struct_: languageType, + name: string, + comment: option, } } -module Validation { - use Types; +module Validation = { + open Types - pub fn is_valid_iso639_1(code: String) -> Bool { re_test("^[a-z]{2}$", code) } - pub fn is_valid_iso639_3(code: String) -> Bool { re_test("^[a-z]{3}$", code) } + fn iso639_1Pattern = %re("/^[a-z]{2}$/") + fn iso639_3Pattern = %re("/^[a-z]{3}$/") - pub fn detect_code_type(code: String) -> Option { - let n = len(code); - if n == 2 && is_valid_iso639_1(code) { - Some(Types.C_Iso639_1) - } else if n == 3 && is_valid_iso639_3(code) { - Some(Types.C_Iso639_3) - } else { - None - } + fn isValidIso639_1 = (code: string): bool => { + Js.Re.test_(iso639_1Pattern, code) } - pub fn normalize(code: String) -> String { str_trim(str_lower(code)) } -} + fn isValidIso639_3 = (code: string): bool => { + Js.Re.test_(iso639_3Pattern, code) + } -module SpecialCodes { - pub let undetermined = "und"; - pub let multiple = "mul"; - pub let miscellaneous = "mis"; - pub let no_linguistic = "zxx"; + fn detectCodeType = (code: string): option => { + fn len = String.length(code) + switch len { + | 2 when isValidIso639_1(code) => Some(Iso639_1) + | 3 when isValidIso639_3(code) => Some(Iso639_3) + | _ => None + } + } - pub fn is_special(code: String) -> Bool { - code == undetermined || code == multiple - || code == miscellaneous || code == no_linguistic + fn normalize = (code: string): string => { + code->String.toLowerCase->String.trim } } -module Conversion { - use Types; +module SpecialCodes = { + // Special ISO 639 codes + fn undetermined = "und" // Undetermined + fn multiple = "mul" // Multiple languages + fn miscellaneous = "mis" // Uncoded languages + fn noLinguistic = "zxx" // No linguistic content + + fn isSpecial = code => { + code == undetermined || + code == multiple || + code == miscellaneous || + code == noLinguistic + } +} - pub fn iso1_to_iso3_table() -> Dict { - let d = dict_empty(); - dict_set(d, "en", "eng"); dict_set(d, "de", "deu"); dict_set(d, "fr", "fra"); - dict_set(d, "es", "spa"); dict_set(d, "it", "ita"); dict_set(d, "pt", "por"); - dict_set(d, "ru", "rus"); dict_set(d, "zh", "zho"); dict_set(d, "ja", "jpn"); - dict_set(d, "ko", "kor"); dict_set(d, "ar", "ara"); dict_set(d, "he", "heb"); - dict_set(d, "el", "ell"); dict_set(d, "la", "lat"); +module Conversion = { + open Types + + // Common ISO 639-1 to ISO 639-3 mappings + fn iso1ToIso3: Dict.t = { + fn d = Dict.make() + Dict.set(d, "en", "eng") + Dict.set(d, "de", "deu") + Dict.set(d, "fr", "fra") + Dict.set(d, "es", "spa") + Dict.set(d, "it", "ita") + Dict.set(d, "pt", "por") + Dict.set(d, "ru", "rus") + Dict.set(d, "zh", "zho") + Dict.set(d, "ja", "jpn") + Dict.set(d, "ko", "kor") + Dict.set(d, "ar", "ara") + Dict.set(d, "he", "heb") + Dict.set(d, "el", "ell") + Dict.set(d, "la", "lat") d } - pub fn to_iso639_3(code: String) -> Option { - let normalized = Validation.normalize(code); - match Validation.detect_code_type(normalized) { - Some(Types.C_Iso639_1) => dict_get(iso1_to_iso3_table(), normalized), - Some(Types.C_Iso639_3) => Some(normalized), - _ => None, + fn toIso639_3 = (code: string): option => { + fn normalized = Validation.normalize(code) + switch Validation.detectCodeType(normalized) { + | Some(Iso639_1) => Dict.get(iso1ToIso3, normalized) + | Some(Iso639_3) => Some(normalized) + | _ => None } } } -module Registry { - use Types; +module Registry = { + open Types - pub type T = { - by_iso3: Dict, - by_iso1: Dict, - by_name: Dict, + struct t { { + byIso3: Dict.t, + byIso1: Dict.t, + byName: Dict.t, } - pub fn empty() -> T { - T { by_iso3: dict_empty(), by_iso1: dict_empty(), by_name: dict_empty() } + fn empty = (): t => { + byIso3: Dict.make(), + byIso1: Dict.make(), + byName: Dict.make(), } - pub fn add(registry: T, entry: Types.LanguageEntry) -> Unit { - dict_set(registry.by_iso3, entry.iso639_3, entry); - match entry.iso639_1 { - Some(code) => dict_set(registry.by_iso1, code, entry), - None => {}, + fn add = (registry, entry: languageEntry) => { + Dict.set(registry.byIso3, entry.iso639_3, entry) + switch entry.iso639_1 { + | Some(code) => Dict.set(registry.byIso1, code, entry) + | None => () } - dict_set(registry.by_name, str_lower(entry.name), entry) + Dict.set(registry.byName, String.toLowerCase(entry.name), entry) } - pub fn find_by_code(registry: T, code: String) -> Option { - let normalized = Validation.normalize(code); - match Validation.detect_code_type(normalized) { - Some(Types.C_Iso639_1) => dict_get(registry.by_iso1, normalized), - Some(Types.C_Iso639_3) => dict_get(registry.by_iso3, normalized), - Some(Types.C_Iso639_2b) => dict_get(registry.by_iso3, normalized), - Some(Types.C_Iso639_2t) => dict_get(registry.by_iso3, normalized), - None => None, + fn findByCode = (registry, code: string): option => { + fn normalized = Validation.normalize(code) + switch Validation.detectCodeType(normalized) { + | Some(Iso639_1) => Dict.get(registry.byIso1, normalized) + | Some(Iso639_3) | Some(Iso639_2b) | Some(Iso639_2t) => + Dict.get(registry.byIso3, normalized) + | None => None } } - pub fn find_by_name(registry: T, name: String) -> Option { - dict_get(registry.by_name, str_lower(name)) + fn findByName = (registry, name: string): option => { + Dict.get(registry.byName, String.toLowerCase(name)) } - pub fn count(registry: T) -> Int { - len(dict_keys(registry.by_iso3)) + fn count = (registry): int => { + Dict.keysToArray(registry.byIso3)->Array.length } } + diff --git a/lol/src/utils/Statistics.affine b/lol/src/utils/Statistics.affine index 0166e24cd..3cd9795e7 100644 --- a/lol/src/utils/Statistics.affine +++ b/lol/src/utils/Statistics.affine @@ -1,20 +1,23 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// Statistical utilities: KL-divergence, entropy, clustering metrics. -// AffineScript port of Statistics.res. +// Ported via Harvard Engine (Semantic pass) module Statistics; -extern fn math_sqrt(x: Float) -> Float = "Math" "sqrt"; -extern fn math_log2(x: Float) -> Float = "Math" "log2"; -extern fn float_nan() -> Float = "Float" "nan"; +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors -module Types { - pub type Distribution = [Float] - pub type Matrix = [[Float]] +/** + * Statistical Utilities + * + * Mathematical and statistical functions for corpus analysis, + * including KL-divergence, entropy, and clustering metrics. + */ - pub type DistanceMetric = +module Types = { + struct distribution { array + struct matrix { array> + + struct distanceMetric { | Euclidean | Cosine | KLDivergence @@ -22,246 +25,228 @@ module Types { | Jaccard } -module Basic { - pub fn sum(arr: [Float]) -> Float { - let acc = 0.0; - let i = 0; - while i < len(arr) { acc = acc +. arr[i]; i = i + 1; } - acc +module Basic = { + fn sum = (arr: array): float => { + arr->Array.reduce(0.0, (acc, x) => acc +. x) } - pub fn mean(arr: [Float]) -> Float { - if len(arr) == 0 { 0.0 } else { sum(arr) /. int_to_float(len(arr)) } + fn mean = (arr: array): float => { + fn len = Array.length(arr) + if len == 0 { + 0.0 + } else { + sum(arr) /. Float.fromInt(len) + } } - pub fn variance(arr: [Float]) -> Float { - if len(arr) == 0 { + fn variance = (arr: array): float => { + fn len = Array.length(arr) + if len == 0 { 0.0 } else { - let m = mean(arr); - let acc = 0.0; - let i = 0; - while i < len(arr) { - let diff = arr[i] -. m; - acc = acc +. diff *. diff; - i = i + 1; - } - acc /. int_to_float(len(arr)) + fn m = mean(arr) + fn squaredDiffs = arr->Array.map(x => { + fn diff = x -. m + diff *. diff + }) + sum(squaredDiffs) /. Float.fromInt(len) } } - pub fn standard_deviation(arr: [Float]) -> Float { math_sqrt(variance(arr)) } + fn standardDeviation = (arr: array): float => { + Math.sqrt(variance(arr)) + } - pub fn min(arr: [Float]) -> Option { - let acc = None; - let i = 0; - while i < len(arr) { - let x = arr[i]; - acc = match acc { None => Some(x), Some(m) => Some(if x < m { x } else { m }) }; - i = i + 1; - } - acc + fn min = (arr: array): option => { + arr->Array.reduce(None, (acc, x) => { + switch acc { + | None => Some(x) + | Some(m) => Some(x < m ? x : m) + } + }) } - pub fn max(arr: [Float]) -> Option { - let acc = None; - let i = 0; - while i < len(arr) { - let x = arr[i]; - acc = match acc { None => Some(x), Some(m) => Some(if x > m { x } else { m }) }; - i = i + 1; - } - acc + fn max = (arr: array): option => { + arr->Array.reduce(None, (acc, x) => { + switch acc { + | None => Some(x) + | Some(m) => Some(x > m ? x : m) + } + }) } } -module Information { - pub let epsilon = 0.0000000001; // 1e-10 +module Information = { + fn epsilon = 1e-10 // Shannon entropy: H(X) = -Σ p(x) log p(x) - pub fn entropy(dist: [Float]) -> Float { - let acc = 0.0; - let i = 0; - while i < len(dist) { - let p = dist[i]; - if p > epsilon { acc = acc -. p *. math_log2(p); } - i = i + 1; - } - acc + fn entropy = (dist: array): float => { + dist->Array.reduce(0.0, (acc, p) => { + if p > epsilon { + acc -. p *. Math.log2(p) + } else { + acc + } + }) } // KL-Divergence: D_KL(P||Q) = Σ P(i) log(P(i)/Q(i)) - pub fn kl_divergence(p: [Float], q: [Float]) -> Float { - if len(p) != len(q) { - float_nan() + fn klDivergence = (p: array, q: array): float => { + if Array.length(p) != Array.length(q) { + Float.Constants.nan } else { - let result = 0.0; - let i = 0; - while i < len(p) { - let pi = p[i]; - let qi = q[i]; - if pi > epsilon && qi > epsilon { result = result +. pi *. math_log2(pi /. qi); } - i = i + 1; + fn result = ref(0.0) + for i in 0 to Array.length(p) - 1 { + fn pi = Array.getUnsafe(p, i) + fn qi = Array.getUnsafe(q, i) + if pi > epsilon && qi > epsilon { + result := result.contents +. pi *. Math.log2(pi /. qi) + } } - result + result.contents } } - pub fn symmetric_kl(p: [Float], q: [Float]) -> Float { - (kl_divergence(p, q) +. kl_divergence(q, p)) /. 2.0 + // Symmetric KL-Divergence: (D_KL(P||Q) + D_KL(Q||P)) / 2 + fn symmetricKL = (p: array, q: array): float => { + (klDivergence(p, q) +. klDivergence(q, p)) /. 2.0 } - // JSD(P||Q) = (D_KL(P||M) + D_KL(Q||M)) / 2 where M = (P + Q) / 2 - pub fn jensen_shannon(p: [Float], q: [Float]) -> Float { - if len(p) != len(q) { - float_nan() + // Jensen-Shannon Divergence: JSD(P||Q) = (D_KL(P||M) + D_KL(Q||M)) / 2 + // where M = (P + Q) / 2 + fn jensenShannon = (p: array, q: array): float => { + if Array.length(p) != Array.length(q) { + Float.Constants.nan } else { - let m = []; - let i = 0; - while i < len(p) { - m = m ++ [(p[i] +. q[i]) /. 2.0]; - i = i + 1; - } - (kl_divergence(p, m) +. kl_divergence(q, m)) /. 2.0 + fn m = p->Array.mapWithIndex((pi, i) => { + fn qi = Array.getUnsafe(q, i) + (pi +. qi) /. 2.0 + }) + (klDivergence(p, m) +. klDivergence(q, m)) /. 2.0 } } } -module Distance { - use Types; +module Distance = { + open Types - pub fn euclidean(a: [Float], b: [Float]) -> Float { - if len(a) != len(b) { - float_nan() + fn euclidean = (a: array, b: array): float => { + if Array.length(a) != Array.length(b) { + Float.Constants.nan } else { - let sum_sq = 0.0; - let i = 0; - while i < len(a) { - let diff = a[i] -. b[i]; - sum_sq = sum_sq +. diff *. diff; - i = i + 1; + fn sumSq = ref(0.0) + for i in 0 to Array.length(a) - 1 { + fn diff = Array.getUnsafe(a, i) -. Array.getUnsafe(b, i) + sumSq := sumSq.contents +. diff *. diff } - math_sqrt(sum_sq) + Math.sqrt(sumSq.contents) } } - pub fn cosine(a: [Float], b: [Float]) -> Float { - if len(a) != len(b) { - float_nan() + fn cosine = (a: array, b: array): float => { + if Array.length(a) != Array.length(b) { + Float.Constants.nan } else { - let dot = 0.0; - let norm_a = 0.0; - let norm_b = 0.0; - let i = 0; - while i < len(a) { - let ai = a[i]; - let bi = b[i]; - dot = dot +. ai *. bi; - norm_a = norm_a +. ai *. ai; - norm_b = norm_b +. bi *. bi; - i = i + 1; + fn dot = ref(0.0) + fn normA = ref(0.0) + fn normB = ref(0.0) + for i in 0 to Array.length(a) - 1 { + fn ai = Array.getUnsafe(a, i) + fn bi = Array.getUnsafe(b, i) + dot := dot.contents +. ai *. bi + normA := normA.contents +. ai *. ai + normB := normB.contents +. bi *. bi + } + fn denom = Math.sqrt(normA.contents) *. Math.sqrt(normB.contents) + if denom > 0.0 { + 1.0 -. dot.contents /. denom // Convert similarity to distance + } else { + 0.0 } - let denom = math_sqrt(norm_a) *. math_sqrt(norm_b); - if denom > 0.0 { 1.0 -. dot /. denom } else { 0.0 } } } - pub fn jaccard(a: [Float], b: [Float]) -> Float { - let intersection = 0; - let union = 0; - let i = 0; - while i < len(a) { - let ai = a[i] > 0.0; - let bi = b[i] > 0.0; - if ai && bi { intersection = intersection + 1; } - if ai || bi { union = union + 1; } - i = i + 1; + fn jaccard = (a: array, b: array): float => { + // Treating as binary vectors (presence/absence) + fn intersection = ref(0) + fn union = ref(0) + for i in 0 to Array.length(a) - 1 { + fn ai = Array.getUnsafe(a, i) > 0.0 + fn bi = Array.getUnsafe(b, i) > 0.0 + if ai && bi { intersection := intersection.contents + 1 } + if ai || bi { union := union.contents + 1 } } - if union == 0 { + if union.contents == 0 { 0.0 } else { - 1.0 -. int_to_float(intersection) /. int_to_float(union) + 1.0 -. Float.fromInt(intersection.contents) /. Float.fromInt(union.contents) } } - pub fn compute(metric: Types.DistanceMetric, a: [Float], b: [Float]) -> Float { - match metric { - Euclidean => euclidean(a, b), - Cosine => cosine(a, b), - KLDivergence => Information.symmetric_kl(a, b), - JensenShannon => Information.jensen_shannon(a, b), - Jaccard => jaccard(a, b), + fn compute = (metric: distanceMetric, a: array, b: array): float => { + switch metric { + | Euclidean => euclidean(a, b) + | Cosine => cosine(a, b) + | KLDivergence => Information.symmetricKL(a, b) + | JensenShannon => Information.jensenShannon(a, b) + | Jaccard => jaccard(a, b) } } } -module Matrix { - use Types; +module Matrix = { + open Types - pub fn distance_matrix(vectors: [[Float]], metric: Types.DistanceMetric) -> Types.Matrix { - let n = len(vectors); - let result = []; - let i = 0; - while i < n { - let row = array_fill(n, 0.0); - let j = 0; - while j < n { + fn distanceMatrix = (vectors: array>, metric: distanceMetric): matrix => { + fn n = Array.length(vectors) + fn result = Array.make(~length=n, []) + for i in 0 to n - 1 { + fn row = Array.make(~length=n, 0.0) + for j in 0 to n - 1 { if i == j { - row[j] = 0.0; + Array.setUnsafe(row, j, 0.0) } else if j < i { - row[j] = result[j][i]; // symmetry + // Use symmetry + Array.setUnsafe(row, j, Array.getUnsafe(Array.getUnsafe(result, j), i)) } else { - row[j] = Distance.compute(metric, vectors[i], vectors[j]); + fn vi = Array.getUnsafe(vectors, i) + fn vj = Array.getUnsafe(vectors, j) + Array.setUnsafe(row, j, Distance.compute(metric, vi, vj)) } - j = j + 1; } - result = result ++ [row]; - i = i + 1; + Array.setUnsafe(result, i, row) } result } } -module Normalization { - pub fn normalize(arr: [Float]) -> [Float] { - let total = Basic.sum(arr); +module Normalization = { + fn normalize = (arr: array): array => { + fn total = Basic.sum(arr) if total > 0.0 { - let out = []; - let i = 0; - while i < len(arr) { out = out ++ [arr[i] /. total]; i = i + 1; } - out + arr->Array.map(x => x /. total) } else { arr } } - pub fn min_max_normalize(arr: [Float]) -> [Float] { - match (Basic.min(arr), Basic.max(arr)) { - (Some(min_val), Some(max_val)) => { - if max_val > min_val { - let range = max_val -. min_val; - let out = []; - let i = 0; - while i < len(arr) { out = out ++ [(arr[i] -. min_val) /. range]; i = i + 1; } - out - } else { - arr - } - } - _ => arr, + fn minMaxNormalize = (arr: array): array => { + switch (Basic.min(arr), Basic.max(arr)) { + | (Some(minVal), Some(maxVal)) when maxVal > minVal => + fn range = maxVal -. minVal + arr->Array.map(x => (x -. minVal) /. range) + | _ => arr } } - pub fn z_score_normalize(arr: [Float]) -> [Float] { - let m = Basic.mean(arr); - let sd = Basic.standard_deviation(arr); + fn zScoreNormalize = (arr: array): array => { + fn m = Basic.mean(arr) + fn sd = Basic.standardDeviation(arr) if sd > 0.0 { - let out = []; - let i = 0; - while i < len(arr) { out = out ++ [(arr[i] -. m) /. sd]; i = i + 1; } - out + arr->Array.map(x => (x -. m) /. sd) } else { arr } } } + diff --git a/lol/src/verisimdb/CorpusAnalyzer.affine b/lol/src/verisimdb/CorpusAnalyzer.affine index 6f0fa3059..dc125baff 100644 --- a/lol/src/verisimdb/CorpusAnalyzer.affine +++ b/lol/src/verisimdb/CorpusAnalyzer.affine @@ -1,366 +1,352 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// Corpus analyzer: quality analysis for the multilingual Bible corpus. -// AffineScript port of CorpusAnalyzer.res. +// Ported via Harvard Engine (Semantic pass) module CorpusAnalyzer; -use VeriSimDB; -use Statistics; -use Lang1000; +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors -extern fn str_split(s: String, sep: String) -> [String] = "string" "split"; -extern fn str_trim(s: String) -> String = "string" "trim"; -extern fn str_includes(s: String, needle: String) -> Bool = "string" "includes"; -extern fn math_abs(x: Float) -> Float = "Math" "abs"; -extern fn float_to_fixed(x: Float, digits: Int) -> String = "Float" "toFixed"; -extern fn date_now_iso() -> String = "Date" "toISOString"; +/** + * Corpus Analyzer + * + * Quality analysis for multilingual Bible corpus data. Detects weak + * points including missing verses, encoding errors, alignment failures, + * statistical outliers, coverage gaps, truncation, and duplicates. + */ -module EntropyAnalysis { - // Character-level entropy for a text. - pub fn character_entropy(text: String) -> Float { - let chars = str_split(text, ""); - let total = int_to_float(len(chars)); +open VeriSimDB + +module EntropyAnalysis = { + /** Compute character-level entropy for a text */ + fn characterEntropy = (text: string): float => { + fn chars = text->String.split("") + fn total = Array.length(chars)->Float.fromInt if total == 0.0 { 0.0 } else { - let counts = dict_empty(); - let i = 0; - while i < len(chars) { - let c = chars[i]; - let cur = match dict_get(counts, c) { Some(n) => n, None => 0 }; - dict_set(counts, c, cur + 1); - i = i + 1; - } - let vals = dict_values(counts); - let dist = []; - let j = 0; - while j < len(vals) { - dist = dist ++ [int_to_float(vals[j]) /. total]; - j = j + 1; - } + fn counts = Dict.make() + chars->Array.forEach(c => { + fn current = counts->Dict.get(c)->Option.getOr(0) + counts->Dict.set(c, current + 1) + }) + fn dist = counts->Dict.valuesToArray->Array.map(c => Float.fromInt(c) /. total) Statistics.Information.entropy(dist) } } - pub fn detect_outliers(corpus: Lang1000.Corpus.T, z_threshold: Float) -> [VeriSimDB.WeakPoint] { - let entropies = []; - let i = 0; - while i < len(corpus.alignments) { - let alignment = corpus.alignments[i]; - let texts = dict_values(alignment.translations); - let avg = if len(texts) == 0 { - 0.0 - } else { - let ent_vals = []; - let k = 0; - while k < len(texts) { ent_vals = ent_vals ++ [character_entropy(texts[k])]; k = k + 1; } - Statistics.Basic.mean(ent_vals) - }; - entropies = entropies ++ [(alignment.reference_id, avg)]; - i = i + 1; - } + /** Flag texts with abnormally low or high entropy as outliers */ + fn detectOutliers = ( + corpus: Lang1000.Corpus.t, + ~zThreshold=2.0, + (), + ): array => { + fn entropies = + corpus.alignments->Array.map(alignment => { + fn texts = alignment.translations->Dict.valuesToArray + fn avgEntropy = if Array.length(texts) == 0 { + 0.0 + } else { + fn entVals = texts->Array.map(characterEntropy) + Statistics.Basic.mean(entVals) + } + (alignment.referenceId, avgEntropy) + }) - let entropy_values = []; - let v = 0; - while v < len(entropies) { - let (_, e) = entropies[v]; - entropy_values = entropy_values ++ [e]; - v = v + 1; - } - let mean = Statistics.Basic.mean(entropy_values); - let sd = Statistics.Basic.standard_deviation(entropy_values); + fn entropyValues = entropies->Array.map(((_, e)) => e) + fn mean = Statistics.Basic.mean(entropyValues) + fn sd = Statistics.Basic.standardDeviation(entropyValues) if sd == 0.0 { [] } else { - let out = []; - let j = 0; - while j < len(entropies) { - let (ref_id, ent) = entropies[j]; - let z = math_abs((ent -. mean) /. sd); - if z > z_threshold { - out = out ++ [VeriSimDB.make_weak_point( - VeriSimDB.Category.StatisticalOutlier, VeriSimDB.Severity.Medium, ref_id, - "Entropy z-score " ++ float_to_fixed(z, 2) ++ " exceeds threshold " - ++ float_to_fixed(z_threshold, 1) ++ " (entropy=" ++ float_to_fixed(ent, 3) ++ ")", - None, None, None)]; + entropies->Array.filterMap(((refId, ent)) => { + fn zScore = Math.abs((ent -. mean) /. sd) + if zScore > zThreshold { + Some( + makeWeakPoint( + ~category=StatisticalOutlier, + ~severity=Medium, + ~location=refId, + ~description=`Entropy z-score ${Float.toFixed(zScore, ~digits=2)} exceeds threshold ${Float.toFixed(zThreshold, ~digits=1)} (entropy=${Float.toFixed(ent, ~digits=3)})`, + (), + ), + ) + } else { + None } - j = j + 1; - } - out + }) } } } -module MissingVerseDetection { - pub fn detect(corpus: Lang1000.Corpus.T, ref_lang: String) -> [VeriSimDB.WeakPoint] { - let out = []; - let i = 0; - while i < len(corpus.alignments) { - let alignment = corpus.alignments[i]; - let has_ref = match dict_get(alignment.translations, ref_lang) { Some(_) => true, None => false }; - if has_ref { - let langs = dict_keys(alignment.translations); - let missing = []; - let l = 0; - while l < len(corpus.languages) { - let code = corpus.languages[l].code; - if code != ref_lang && !array_includes(langs, code) { - missing = missing ++ [code]; - } - l = l + 1; - } - if len(missing) > 0 { - let sev = if len(missing) > 5 { VeriSimDB.Severity.High } else { VeriSimDB.Severity.Low }; - out = out ++ [VeriSimDB.make_weak_point( - VeriSimDB.Category.MissingVerse, sev, alignment.reference_id, - "Missing in " ++ show(len(missing)) ++ " languages: " ++ join_with(missing, ", "), - None, None, None)]; +module MissingVerseDetection = { + /** Detect verses present in reference language but missing in others */ + fn detect = ( + corpus: Lang1000.Corpus.t, + ~refLang: string, + (), + ): array => { + corpus.alignments->Array.filterMap(alignment => { + fn hasRef = alignment.translations->Dict.get(refLang)->Option.isSome + if !hasRef { + None + } else { + fn langs = alignment.translations->Dict.keysToArray + fn missing = + corpus.languages + ->Array.map(l => l.code) + ->Array.filter(code => code != refLang && !(langs->Array.includes(code))) + + if Array.length(missing) > 0 { + fn missingStr = missing->Array.join(", ") + Some( + makeWeakPoint( + ~category=MissingVerse, + ~severity=if Array.length(missing) > 5 { High } else { Low }, + ~location=alignment.referenceId, + ~description=`Missing in ${Int.toString(Array.length(missing))} languages: ${missingStr}`, + (), + ), + ) + } else { + None } } - i = i + 1; - } - out + }) } } -module EncodingDetection { - pub fn has_encoding_error(text: String) -> Bool { - str_includes(text, "\u{FFFD}") - || str_includes(text, "\u{0000}") - || str_includes(text, "\u{00C3}\u{00A9}") - || str_includes(text, "\u{00C3}\u{00A0}") - || str_includes(text, "\u{00C2}\u{00BB}") +module EncodingDetection = { + /** Check for common encoding errors in text */ + fn hasEncodingError = (text: string): bool => { + // Check for replacement character (U+FFFD) indicating invalid UTF-8 + String.includes(text, "\uFFFD") || + // Check for null bytes + String.includes(text, "\u0000") || + // Check for common mojibake patterns + String.includes(text, "\u00C3\u00A9") || // é instead of é + String.includes(text, "\u00C3\u00A0") || // à instead of à + String.includes(text, "\u00C2\u00BB") // » instead of » } - pub fn detect(corpus: Lang1000.Corpus.T) -> [VeriSimDB.WeakPoint] { - let out = []; - let i = 0; - while i < len(corpus.alignments) { - let alignment = corpus.alignments[i]; - let pairs = dict_entries(alignment.translations); - let j = 0; - while j < len(pairs) { - let (lang, text) = pairs[j]; - if has_encoding_error(text) { - out = out ++ [VeriSimDB.make_weak_point( - VeriSimDB.Category.EncodingError, VeriSimDB.Severity.Medium, - alignment.reference_id, "Encoding error detected in text", None, Some(lang), None)]; + /** Detect encoding errors across the corpus */ + fn detect = (corpus: Lang1000.Corpus.t): array => { + corpus.alignments->Array.flatMap(alignment => { + alignment.translations + ->Dict.toArray + ->Array.filterMap(((lang, text)) => { + if hasEncodingError(text) { + Some( + makeWeakPoint( + ~category=EncodingError, + ~severity=Medium, + ~location=alignment.referenceId, + ~description=`Encoding error detected in text`, + ~language=lang, + (), + ), + ) + } else { + None } - j = j + 1; - } - i = i + 1; - } - out + }) + }) } } -module TruncationDetection { - pub fn detect(corpus: Lang1000.Corpus.T, ref_lang: String, min_ratio: Float) -> [VeriSimDB.WeakPoint] { - let out = []; - let i = 0; - while i < len(corpus.alignments) { - let alignment = corpus.alignments[i]; - match dict_get(alignment.translations, ref_lang) { - None => {} - Some(ref_text) => { - let ref_len = int_to_float(len(ref_text)); - if ref_len >= 5.0 { - let pairs = dict_entries(alignment.translations); - let j = 0; - while j < len(pairs) { - let (lang, text) = pairs[j]; - if lang != ref_lang { - let text_len = int_to_float(len(text)); - let ratio = text_len /. ref_len; - if ratio < min_ratio && text_len > 0.0 { - out = out ++ [VeriSimDB.make_weak_point( - VeriSimDB.Category.TruncatedContent, VeriSimDB.Severity.Medium, - alignment.reference_id, - "Text length ratio " ++ float_to_fixed(ratio, 2) ++ " below threshold " - ++ float_to_fixed(min_ratio, 2) ++ " (" ++ show(float_to_int(text_len)) - ++ " vs " ++ show(float_to_int(ref_len)) ++ " chars)", - None, Some(lang), None)]; - } +module TruncationDetection = { + /** Detect suspiciously short translations compared to reference */ + fn detect = ( + corpus: Lang1000.Corpus.t, + ~refLang: string, + ~minRatio=0.2, + (), + ): array => { + corpus.alignments->Array.flatMap(alignment => { + fn refText = alignment.translations->Dict.get(refLang) + switch refText { + | None => [] + | Some(ref) => + fn refLen = String.length(ref)->Float.fromInt + if refLen < 5.0 { + [] + } else { + alignment.translations + ->Dict.toArray + ->Array.filterMap(((lang, text)) => { + if lang == refLang { + None + } else { + fn textLen = String.length(text)->Float.fromInt + fn ratio = textLen /. refLen + if ratio < minRatio && textLen > 0.0 { + Some( + makeWeakPoint( + ~category=TruncatedContent, + ~severity=Medium, + ~location=alignment.referenceId, + ~description=`Text length ratio ${Float.toFixed(ratio, ~digits=2)} below threshold ${Float.toFixed(minRatio, ~digits=2)} (${Int.toString(Float.toInt(textLen))} vs ${Int.toString(Float.toInt(refLen))} chars)`, + ~language=lang, + (), + ), + ) + } else { + None } - j = j + 1; } - } + }) } } - i = i + 1; - } - out + }) } } -module DuplicateDetection { - pub fn detect(corpus: Lang1000.Corpus.T) -> [VeriSimDB.WeakPoint] { - let lang_texts = dict_empty(); +module DuplicateDetection = { + /** Detect identical text for different verses within a language */ + fn detect = (corpus: Lang1000.Corpus.t): array => { + fn langTexts: Dict.t>> = Dict.make() - let i = 0; - while i < len(corpus.alignments) { - let alignment = corpus.alignments[i]; - let pairs = dict_entries(alignment.translations); - let j = 0; - while j < len(pairs) { - let (lang, text) = pairs[j]; - let trimmed = str_trim(text); - if len(trimmed) > 10 { - let lang_dict = match dict_get(lang_texts, lang) { - Some(d) => d, - None => { let d = dict_empty(); dict_set(lang_texts, lang, d); d } - }; - let refs = match dict_get(lang_dict, trimmed) { Some(r) => r, None => [] }; - dict_set(lang_dict, trimmed, refs ++ [alignment.reference_id]); + // Group by language -> text -> [referenceIds] + corpus.alignments->Array.forEach(alignment => { + alignment.translations + ->Dict.toArray + ->Array.forEach(((lang, text)) => { + fn trimmed = String.trim(text) + if String.length(trimmed) > 10 { + fn langDict = switch langTexts->Dict.get(lang) { + | Some(d) => d + | None => + fn d = Dict.make() + langTexts->Dict.set(lang, d) + d + } + fn refs = switch langDict->Dict.get(trimmed) { + | Some(r) => r + | None => [] + } + langDict->Dict.set(trimmed, Array.concat(refs, [alignment.referenceId])) } - j = j + 1; - } - i = i + 1; - } + }) + }) - let out = []; - let langs = dict_entries(lang_texts); - let l = 0; - while l < len(langs) { - let (lang, text_dict) = langs[l]; - let texts = dict_entries(text_dict); - let t = 0; - while t < len(texts) { - let (_, refs) = texts[t]; - if len(refs) > 1 { - let head = []; - let h = 0; - while h < len(refs) && h < 5 { head = head ++ [refs[h]]; h = h + 1; } - out = out ++ [VeriSimDB.make_weak_point( - VeriSimDB.Category.DuplicateContent, VeriSimDB.Severity.High, refs[0], - "Identical text in " ++ show(len(refs)) ++ " verses: " ++ join_with(head, ", "), - None, Some(lang), None)]; + langTexts + ->Dict.toArray + ->Array.flatMap(((lang, textDict)) => { + textDict + ->Dict.toArray + ->Array.filterMap(((_, refs)) => { + if Array.length(refs) > 1 { + fn refsStr = refs->Array.slice(~start=0, ~end=5)->Array.join(", ") + Some( + makeWeakPoint( + ~category=DuplicateContent, + ~severity=High, + ~location=Array.getUnsafe(refs, 0), + ~description=`Identical text in ${Int.toString(Array.length(refs))} verses: ${refsStr}`, + ~language=lang, + (), + ), + ) + } else { + None } - t = t + 1; - } - l = l + 1; - } - out + }) + }) } } -module CoverageAnalysis { - pub fn detect_gaps(source_coverage: [(String, [String])]) -> [VeriSimDB.WeakPoint] { - let all_langs = dict_empty(); - let i = 0; - while i < len(source_coverage) { - let (source, langs) = source_coverage[i]; - let j = 0; - while j < len(langs) { - let lang = langs[j]; - let sources = match dict_get(all_langs, lang) { Some(s) => s, None => [] }; - dict_set(all_langs, lang, sources ++ [source]); - j = j + 1; - } - i = i + 1; - } +module CoverageAnalysis = { + /** Detect languages present in one source but missing from others */ + fn detectGaps = ( + ~sourceCoverage: array<(string, array)>, + (), + ): array => { + // Build union of all languages + fn allLangs = Dict.make() + sourceCoverage->Array.forEach(((source, langs)) => { + langs->Array.forEach(lang => { + fn sources = switch allLangs->Dict.get(lang) { + | Some(s) => s + | None => [] + } + allLangs->Dict.set(lang, Array.concat(sources, [source])) + }) + }) - let total_sources = len(source_coverage); - let out = []; - let entries = dict_entries(all_langs); - let k = 0; - while k < len(entries) { - let (lang, sources) = entries[k]; - if len(sources) < total_sources && len(sources) == 1 { - out = out ++ [VeriSimDB.make_weak_point( - VeriSimDB.Category.CoverageGap, VeriSimDB.Severity.Low, lang, - "Only available from " ++ sources[0] ++ ", missing from " - ++ show(total_sources - 1) ++ " other sources", - None, Some(lang), None)]; + fn totalSources = Array.length(sourceCoverage) + allLangs + ->Dict.toArray + ->Array.filterMap(((lang, sources)) => { + if Array.length(sources) < totalSources && Array.length(sources) == 1 { + Some( + makeWeakPoint( + ~category=CoverageGap, + ~severity=Low, + ~location=lang, + ~description=`Only available from ${Array.getUnsafe(sources, 0)}, missing from ${Int.toString(totalSources - 1)} other sources`, + ~language=lang, + (), + ), + ) + } else { + None } - k = k + 1; - } - out + }) } } -fn join_with(parts: [String], sep: String) -> String { - let out = ""; - let i = 0; - while i < len(parts) { - out = if i == 0 { parts[i] } else { out ++ sep ++ parts[i] }; - i = i + 1; - } - out -} +/** Run all analysis checks on a corpus and return a complete scan result */ +fn analyzeFull = ( + corpus: Lang1000.Corpus.t, + ~refLang="eng", + ~repo="lol", + ~version="0.1.0", + (), +): scanResult => { + fn weakPoints = Array.concatMany([ + EntropyAnalysis.detectOutliers(corpus, ()), + MissingVerseDetection.detect(corpus, ~refLang, ()), + EncodingDetection.detect(corpus), + TruncationDetection.detect(corpus, ~refLang, ()), + DuplicateDetection.detect(corpus), + ]) -pub fn analyze_full(corpus: Lang1000.Corpus.T, ref_lang: String, - repo: String, version: String) -> VeriSimDB.ScanResult { - let weak_points = EntropyAnalysis.detect_outliers(corpus, 2.0) - ++ MissingVerseDetection.detect(corpus, ref_lang) - ++ EncodingDetection.detect(corpus) - ++ TruncationDetection.detect(corpus, ref_lang, 0.2) - ++ DuplicateDetection.detect(corpus); + fn totalLines = + corpus.alignments->Array.reduce(0, (acc, a) => + acc + Dict.keysToArray(a.translations)->Array.length + ) - let total_lines = 0; - let a = 0; - while a < len(corpus.alignments) { - total_lines = total_lines + len(dict_keys(corpus.alignments[a].translations)); - a = a + 1; - } - - fn count_unsafe(wps: [VeriSimDB.WeakPoint]) -> Int { - let n = 0; - let i = 0; - while i < len(wps) { - if VeriSimDB.Severity.to_numeric(wps[i].severity) >= 4 { n = n + 1; } - i = i + 1; - } - n - } - - let files = []; - let l = 0; - while l < len(corpus.languages) { - let lang = corpus.languages[l]; - let lang_wps = []; - let w = 0; - while w < len(weak_points) { - let wp = weak_points[w]; - if (match wp.language { Some(x) => x, None => "" }) == lang.code { - lang_wps = lang_wps ++ [wp]; - } - w = w + 1; - } - let lines = 0; - let aa = 0; - while aa < len(corpus.alignments) { - match dict_get(corpus.alignments[aa].translations, lang.code) { - Some(_) => { lines = lines + 1; } - None => {} - } - aa = aa + 1; - } - files = files ++ [VeriSimDB.FileStatistic { - file: lang.code, - total_lines: lines, - weak_points: len(lang_wps), - unsafe_blocks: count_unsafe(lang_wps), - }]; - l = l + 1; - } - - VeriSimDB.ScanResult { - repo: repo, - version: version, - timestamp: date_now_iso(), + { + repo, + version, + timestamp: Date.make()->Date.toISOString, scanner: "lol-corpus-analyzer", scanner_version: "0.1.0", - weak_points: weak_points, - statistics: VeriSimDB.CorpusStatistics { - total_files: Lang1000.Corpus.language_count(corpus), - total_lines: total_lines, - total_weak_points: len(weak_points), - total_unsafe_blocks: count_unsafe(weak_points), - files: files, + weak_points: weakPoints, + statistics: { + total_files: Lang1000.Corpus.languageCount(corpus), + total_lines: totalLines, + total_weak_points: Array.length(weakPoints), + total_unsafe_blocks: weakPoints + ->Array.filter(wp => Severity.toNumeric(wp.severity) >= 4) + ->Array.length, + files: corpus.languages->Array.map(lang => { + fn langWPs = + weakPoints->Array.filter(wp => + wp.language->Option.getOr("") == lang.code + ) + { + file: lang.code, + total_lines: corpus.alignments + ->Array.filter(a => a.translations->Dict.get(lang.code)->Option.isSome) + ->Array.length, + weak_points: Array.length(langWPs), + unsafe_blocks: langWPs + ->Array.filter(wp => Severity.toNumeric(wp.severity) >= 4) + ->Array.length, + } + }), }, } } + diff --git a/lol/src/verisimdb/Export.affine b/lol/src/verisimdb/Export.affine index fcdf4b77d..32d4cc099 100644 --- a/lol/src/verisimdb/Export.affine +++ b/lol/src/verisimdb/Export.affine @@ -1,96 +1,87 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// VeriSimDB JSON export. AffineScript port of Export.res. +// Ported via Harvard Engine (Semantic pass) module Export; -use VeriSimDB; +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors -extern fn str_replace_all(s: String, from: String, to: String) -> String = "string" "replaceAll"; -extern fn write_text_file(path: String, contents: String) -> Promise = "Deno" "writeTextFile"; +/** + * VeriSimDB JSON Export + * + * Serializes corpus scan results to verisimdb-data compatible JSON + * format for ingestion into the VeriSimDB pipeline. + */ -module Json { - pub fn escape_string(s: String) -> String { - let a = str_replace_all(s, "\\", "\\\\"); - let b = str_replace_all(a, "\"", "\\\""); - let c = str_replace_all(b, "\n", "\\n"); - let d = str_replace_all(c, "\r", "\\r"); - str_replace_all(d, "\t", "\\t") - } +open VeriSimDB - pub fn str(s: String) -> String { "\"" ++ escape_string(s) ++ "\"" } - pub fn int(n: Int) -> String { show(n) } - pub fn opt_str(o: Option) -> String { - match o { Some(s) => str(s), None => "null" } +module Json = { + /** Escape a string for JSON output */ + fn escapeString = (s: string): string => { + s + ->String.replaceAll("\\", "\\\\") + ->String.replaceAll("\"", "\\\"") + ->String.replaceAll("\n", "\\n") + ->String.replaceAll("\r", "\\r") + ->String.replaceAll("\t", "\\t") } -} -pub fn weak_point_to_json(wp: VeriSimDB.WeakPoint) -> String { - "{" - ++ "\"category\": " ++ Json.str(VeriSimDB.Category.to_string(wp.category)) ++ ", " - ++ "\"severity\": " ++ Json.str(VeriSimDB.Severity.to_string(wp.severity)) ++ ", " - ++ "\"location\": " ++ Json.str(wp.location) ++ ", " - ++ "\"description\": " ++ Json.str(wp.description) ++ ", " - ++ "\"context\": " ++ Json.opt_str(wp.context) ++ ", " - ++ "\"language\": " ++ Json.opt_str(wp.language) ++ ", " - ++ "\"source\": " ++ Json.opt_str(wp.source) - ++ "}" + fn str = (s: string): string => `"${escapeString(s)}"` + fn int = (n: int): string => Int.toString(n) + fn optStr = (o: option): string => + switch o { + | Some(s) => str(s) + | None => "null" + } } -pub fn file_stat_to_json(fs: VeriSimDB.FileStatistic) -> String { - "{\"file\": " ++ Json.str(fs.file) - ++ ", \"total_lines\": " ++ Json.int(fs.total_lines) - ++ ", \"weak_points\": " ++ Json.int(fs.weak_points) - ++ ", \"unsafe_blocks\": " ++ Json.int(fs.unsafe_blocks) ++ "}" +fn weakPointToJson = (wp: weakPoint): string => { + fn fields = [ + `"category": ${Json.str(Category.toString(wp.category))}`, + `"severity": ${Json.str(Severity.toString(wp.severity))}`, + `"location": ${Json.str(wp.location)}`, + `"description": ${Json.str(wp.description)}`, + `"context": ${Json.optStr(wp.context)}`, + `"language": ${Json.optStr(wp.language)}`, + `"source": ${Json.optStr(wp.source)}`, + ] + `{${fields->Array.join(", ")}}` } -fn join_with(parts: [String], sep: String) -> String { - let out = ""; - let i = 0; - while i < len(parts) { - out = if i == 0 { parts[i] } else { out ++ sep ++ parts[i] }; - i = i + 1; - } - out +fn fileStatToJson = (fs: fileStatistic): string => { + `{"file": ${Json.str(fs.file)}, "total_lines": ${Json.int(fs.total_lines)}, "weak_points": ${Json.int(fs.weak_points)}, "unsafe_blocks": ${Json.int(fs.unsafe_blocks)}}` } -pub fn statistics_to_json(stats: VeriSimDB.CorpusStatistics) -> String { - let files_parts = []; - let i = 0; - while i < len(stats.files) { - files_parts = files_parts ++ [file_stat_to_json(stats.files[i])]; - i = i + 1; - } - let files_json = join_with(files_parts, ", "); - "{\"total_files\": " ++ Json.int(stats.total_files) - ++ ", \"total_lines\": " ++ Json.int(stats.total_lines) - ++ ", \"total_weak_points\": " ++ Json.int(stats.total_weak_points) - ++ ", \"total_unsafe_blocks\": " ++ Json.int(stats.total_unsafe_blocks) - ++ ", \"files\": [" ++ files_json ++ "]}" +fn statisticsToJson = (stats: corpusStatistics): string => { + fn filesJson = stats.files->Array.map(fileStatToJson)->Array.join(", ") + `{"total_files": ${Json.int(stats.total_files)}, "total_lines": ${Json.int(stats.total_lines)}, "total_weak_points": ${Json.int(stats.total_weak_points)}, "total_unsafe_blocks": ${Json.int(stats.total_unsafe_blocks)}, "files": [${filesJson}]}` } -pub fn to_json(result: VeriSimDB.ScanResult) -> String { - let wp_parts = []; - let i = 0; - while i < len(result.weak_points) { - wp_parts = wp_parts ++ [weak_point_to_json(result.weak_points[i])]; - i = i + 1; - } - let wp_json = join_with(wp_parts, ",\n "); - let stats_json = statistics_to_json(result.statistics); +/** Convert a scan result to verisimdb-data compatible JSON string */ +fn toJson = (result: scanResult): string => { + fn wpJson = result.weak_points->Array.map(weakPointToJson)->Array.join(",\n ") + fn statsJson = statisticsToJson(result.statistics) - "{\n" - ++ " \"repo\": " ++ Json.str(result.repo) ++ ",\n" - ++ " \"version\": " ++ Json.str(result.version) ++ ",\n" - ++ " \"timestamp\": " ++ Json.str(result.timestamp) ++ ",\n" - ++ " \"scanner\": " ++ Json.str(result.scanner) ++ ",\n" - ++ " \"scanner_version\": " ++ Json.str(result.scanner_version) ++ ",\n" - ++ " \"weak_points\": [\n " ++ wp_json ++ "\n ],\n" - ++ " \"statistics\": " ++ stats_json ++ "\n" - ++ "}" + `{ + "repo": ${Json.str(result.repo)}, + "version": ${Json.str(result.version)}, + "timestamp": ${Json.str(result.timestamp)}, + "scanner": ${Json.str(result.scanner)}, + "scanner_version": ${Json.str(result.scanner_version)}, + "weak_points": [ + ${wpJson} + ], + "statistics": ${statsJson} +}` } -pub fn write_to_file(result: VeriSimDB.ScanResult, path: String) -> Effect[Async] Unit { - await write_text_file(path, to_json(result)) +/** Deno.writeTextFile binding */ +@val @scope("Deno") +external writeTextFile: (string, string) => promise = "writeTextFile" + +/** Write scan result JSON to a file */ +fn writeToFile = async (result: scanResult, path: string): unit => { + fn json = toJson(result) + await writeTextFile(path, json) } + diff --git a/lol/src/verisimdb/VeriSimDB.affine b/lol/src/verisimdb/VeriSimDB.affine index c3f897360..2da0790ab 100644 --- a/lol/src/verisimdb/VeriSimDB.affine +++ b/lol/src/verisimdb/VeriSimDB.affine @@ -1,39 +1,48 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// VeriSimDB types matching the verisimdb-data JSON schema. -// AffineScript port of VeriSimDB.res. +// Ported via Harvard Engine (Semantic pass) module VeriSimDB; -extern fn date_now_iso() -> String = "Date" "toISOString"; +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors -module Severity { - pub type T = | Critical | High | Medium | Low | Info +/** + * VeriSimDB Types + * + * Types matching the verisimdb-data JSON schema for corpus quality + * verification and weak point tracking. Maps corpus-specific metrics + * to the VeriSimDB pipeline format. + */ - pub fn to_string(s: T) -> String { - match s { - Critical => "critical", - High => "high", - Medium => "medium", - Low => "low", - Info => "info", +module Severity = { + struct t { + | Critical + | High + | Medium + | Low + | Info + + fn toString = severity => + switch severity { + | Critical => "critical" + | High => "high" + | Medium => "medium" + | Low => "low" + | Info => "info" } - } - pub fn to_numeric(s: T) -> Int { - match s { - Critical => 5, - High => 4, - Medium => 3, - Low => 2, - Info => 1, + fn toNumeric = severity => + switch severity { + | Critical => 5 + | High => 4 + | Medium => 3 + | Low => 2 + | Info => 1 } - } } -module Category { - pub type T = +module Category = { + struct t { | MissingVerse | EncodingError | AlignmentFailure @@ -42,75 +51,85 @@ module Category { | TruncatedContent | DuplicateContent - pub fn to_string(c: T) -> String { - match c { - MissingVerse => "missing-verse", - EncodingError => "encoding-error", - AlignmentFailure => "alignment-failure", - StatisticalOutlier => "statistical-outlier", - CoverageGap => "coverage-gap", - TruncatedContent => "truncated-content", - DuplicateContent => "duplicate-content", + fn toString = category => + switch category { + | MissingVerse => "missing-verse" + | EncodingError => "encoding-error" + | AlignmentFailure => "alignment-failure" + | StatisticalOutlier => "statistical-outlier" + | CoverageGap => "coverage-gap" + | TruncatedContent => "truncated-content" + | DuplicateContent => "duplicate-content" } - } } -pub type WeakPoint = { - category: Category.T, - severity: Severity.T, - location: String, - description: String, - context: Option, - language: Option, - source: Option, +struct weakPoint { { + category: Category.t, + severity: Severity.t, + location: string, + description: string, + context: option, + language: option, + source: option, } -pub type FileStatistic = { - file: String, - total_lines: Int, - weak_points: Int, - unsafe_blocks: Int, +struct fileStatistic { { + file: string, + total_lines: int, + weak_points: int, + unsafe_blocks: int, } -pub type CorpusStatistics = { - total_files: Int, - total_lines: Int, - total_weak_points: Int, - total_unsafe_blocks: Int, - files: [FileStatistic], +struct corpusStatistics { { + total_files: int, + total_lines: int, + total_weak_points: int, + total_unsafe_blocks: int, + files: array, } -pub type ScanResult = { - repo: String, - version: String, - timestamp: String, - scanner: String, - scanner_version: String, - weak_points: [WeakPoint], - statistics: CorpusStatistics, +struct scanResult { { + repo: string, + version: string, + timestamp: string, + scanner: string, + scanner_version: string, + weak_points: array, + statistics: corpusStatistics, } -pub fn make_weak_point(category: Category.T, severity: Severity.T, - location: String, description: String, - context: Option, language: Option, - source: Option) -> WeakPoint { - WeakPoint { - category: category, severity: severity, location: location, - description: description, context: context, language: language, source: source, - } +fn makeWeakPoint = ( + ~category, + ~severity, + ~location, + ~description, + ~context=?, + ~language=?, + ~source=?, + (), +) => { + category, + severity, + location, + description, + context, + language, + source, } -pub fn empty_scan_result(repo: String, version: String) -> ScanResult { - ScanResult { - repo: repo, - version: version, - timestamp: date_now_iso(), - scanner: "lol-corpus-analyzer", - scanner_version: "0.1.0", - weak_points: [], - statistics: CorpusStatistics { - total_files: 0, total_lines: 0, total_weak_points: 0, - total_unsafe_blocks: 0, files: [], - }, - } +fn emptyScanResult = (~repo, ~version) => { + repo, + version, + timestamp: Date.make()->Date.toISOString, + scanner: "lol-corpus-analyzer", + scanner_version: "0.1.0", + weak_points: [], + statistics: { + total_files: 0, + total_lines: 0, + total_weak_points: 0, + total_unsafe_blocks: 0, + files: [], + }, } + diff --git a/lol/test/Lang1000_test.affine b/lol/test/Lang1000_test.affine index c9f0e82ef..db504ba8b 100644 --- a/lol/test/Lang1000_test.affine +++ b/lol/test/Lang1000_test.affine @@ -1,79 +1,102 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// Main module tests. AffineScript port of Lang1000_test.res. +// Ported via Harvard Engine (Semantic pass) module Lang1000_test; -use Vitest; -use Lang1000; - -Vitest.describe("Lang1000.Config", fn() { - Vitest.test("version is defined", fn() { - Vitest.to_be(Vitest.expect(Lang1000.Config.version), "0.1.0") - }); - Vitest.test("name is correct", fn() { - Vitest.to_be(Vitest.expect(Lang1000.Config.name), "1000Langs") - }); - Vitest.test("allSources contains expected sources", fn() { - Vitest.to_be(Vitest.expect(len(Lang1000.Config.all_sources)), 6) - }); - Vitest.test("sourceToString converts correctly", fn() { - Vitest.to_be(Vitest.expect(Lang1000.Config.source_to_string(Lang1000.Config.BibleCloud)), "bible.cloud"); - Vitest.to_be(Vitest.expect(Lang1000.Config.source_to_string(Lang1000.Config.BibleCom)), "bible.com"); - Vitest.to_be(Vitest.expect(Lang1000.Config.source_to_string(Lang1000.Config.PngScriptures)), "pngscriptures.org") +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors + +/** + * Main Module Tests + */ + +open Vitest + +describe("Lang1000.Config", () => { + test("version is defined", () => { + expect(Lang1000.Config.version)->toBe("0.1.0") + }) + + test("name is correct", () => { + expect(Lang1000.Config.name)->toBe("1000Langs") + }) + + test("allSources contains expected sources", () => { + fn sources = Lang1000.Config.allSources + expect(Array.length(sources))->toBe(6) + }) + + test("sourceToString converts correctly", () => { + expect(Lang1000.Config.sourceToString(Lang1000.Config.BibleCloud))->toBe("bible.cloud") + expect(Lang1000.Config.sourceToString(Lang1000.Config.BibleCom))->toBe("bible.com") + expect(Lang1000.Config.sourceToString(Lang1000.Config.PngScriptures))->toBe("pngscriptures.org") }) -}); - -Vitest.describe("Lang1000.Language", fn() { - Vitest.test("make creates a language with required fields", fn() { - let lang = Lang1000.Language.make("eng", "English", None, None, None); - Vitest.to_be(Vitest.expect(Lang1000.Language.get_code(lang)), "eng"); - Vitest.to_be(Vitest.expect(Lang1000.Language.get_name(lang)), "English") - }); - Vitest.test("make creates a language with optional fields", fn() { - let lang = Lang1000.Language.make("deu", "German", - Some("Indo-European"), Some("Latin"), Some("Germany")); - Vitest.to_equal(Vitest.expect(lang.family), Some("Indo-European")); - Vitest.to_equal(Vitest.expect(lang.script), Some("Latin")); - Vitest.to_equal(Vitest.expect(lang.country), Some("Germany")) +}) + +describe("Lang1000.Language", () => { + test("make creates a language with required fields", () => { + fn lang = Lang1000.Language.make(~code="eng", ~name="English", ()) + expect(Lang1000.Language.getCode(lang))->toBe("eng") + expect(Lang1000.Language.getName(lang))->toBe("English") }) -}); - -Vitest.describe("Lang1000.Verse", fn() { - Vitest.test("makeReference creates a valid reference", fn() { - let r = Lang1000.Verse.make_reference("GEN", 1, 1); - Vitest.to_be(Vitest.expect(r.book), "GEN"); - Vitest.to_be(Vitest.expect(r.chapter), 1); - Vitest.to_be(Vitest.expect(r.verse), 1) - }); - Vitest.test("toCanonicalId formats correctly", fn() { - let r = Lang1000.Verse.make_reference("GEN", 1, 1); - Vitest.to_be(Vitest.expect(Lang1000.Verse.to_canonical_id(r)), "GEN.1.1") - }); - Vitest.test("toCanonicalId handles multi-digit chapters and verses", fn() { - let r = Lang1000.Verse.make_reference("PSA", 119, 176); - Vitest.to_be(Vitest.expect(Lang1000.Verse.to_canonical_id(r)), "PSA.119.176") + + test("make creates a language with optional fields", () => { + fn lang = Lang1000.Language.make( + ~code="deu", + ~name="German", + ~family="Indo-European", + ~script="Latin", + ~country="Germany", + (), + ) + expect(lang.family)->toEqual(Some("Indo-European")) + expect(lang.script)->toEqual(Some("Latin")) + expect(lang.country)->toEqual(Some("Germany")) }) -}); - -Vitest.describe("Lang1000.Corpus", fn() { - Vitest.test("empty creates an empty corpus", fn() { - let corpus = Lang1000.Corpus.empty("TestCorpus"); - Vitest.to_be(Vitest.expect(corpus.name), "TestCorpus"); - Vitest.to_be(Vitest.expect(Lang1000.Corpus.language_count(corpus)), 0); - Vitest.to_be(Vitest.expect(Lang1000.Corpus.alignment_count(corpus)), 0) - }); - Vitest.test("addLanguage increases language count", fn() { - let corpus = Lang1000.Corpus.empty("TestCorpus"); - let lang = Lang1000.Language.make("eng", "English", None, None, None); - let updated = Lang1000.Corpus.add_language(corpus, lang); - Vitest.to_be(Vitest.expect(Lang1000.Corpus.language_count(updated)), 1) - }); - Vitest.test("addAlignment increases alignment count", fn() { - let corpus = Lang1000.Corpus.empty("TestCorpus"); - let alignment = Lang1000.Corpus.Alignment { reference_id: "GEN.1.1", translations: dict_empty() }; - let updated = Lang1000.Corpus.add_alignment(corpus, alignment); - Vitest.to_be(Vitest.expect(Lang1000.Corpus.alignment_count(updated)), 1) +}) + +describe("Lang1000.Verse", () => { + test("makeReference creates a valid reference", () => { + fn ref = Lang1000.Verse.makeReference(~book="GEN", ~chapter=1, ~verse=1) + expect(ref.book)->toBe("GEN") + expect(ref.chapter)->toBe(1) + expect(ref.verse)->toBe(1) }) -}); + + test("toCanonicalId formats correctly", () => { + fn ref = Lang1000.Verse.makeReference(~book="GEN", ~chapter=1, ~verse=1) + expect(Lang1000.Verse.toCanonicalId(ref))->toBe("GEN.1.1") + }) + + test("toCanonicalId handles multi-digit chapters and verses", () => { + fn ref = Lang1000.Verse.makeReference(~book="PSA", ~chapter=119, ~verse=176) + expect(Lang1000.Verse.toCanonicalId(ref))->toBe("PSA.119.176") + }) +}) + +describe("Lang1000.Corpus", () => { + test("empty creates an empty corpus", () => { + fn corpus = Lang1000.Corpus.empty("TestCorpus") + expect(corpus.name)->toBe("TestCorpus") + expect(Lang1000.Corpus.languageCount(corpus))->toBe(0) + expect(Lang1000.Corpus.alignmentCount(corpus))->toBe(0) + }) + + test("addLanguage increases language count", () => { + fn corpus = Lang1000.Corpus.empty("TestCorpus") + fn lang = Lang1000.Language.make(~code="eng", ~name="English", ()) + fn updated = Lang1000.Corpus.addLanguage(corpus, lang) + expect(Lang1000.Corpus.languageCount(updated))->toBe(1) + }) + + test("addAlignment increases alignment count", () => { + fn corpus = Lang1000.Corpus.empty("TestCorpus") + fn alignment: Lang1000.Corpus.alignment = { + referenceId: "GEN.1.1", + translations: Dict.make(), + } + fn updated = Lang1000.Corpus.addAlignment(corpus, alignment) + expect(Lang1000.Corpus.alignmentCount(updated))->toBe(1) + }) +}) + diff --git a/lol/test/Vitest.affine b/lol/test/Vitest.affine index 33a2edced..6b63c7de4 100644 --- a/lol/test/Vitest.affine +++ b/lol/test/Vitest.affine @@ -1,43 +1,68 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// Minimal bindings to the Vitest testing framework. -// AffineScript port of Vitest.res. +// Ported via Harvard Engine (Semantic pass) module Vitest; -extern fn describe(name: String, body: fn() -> Unit) -> Unit = "vitest" "describe"; -extern fn test(name: String, body: fn() -> Unit) -> Unit = "vitest" "test"; -extern fn it(name: String, body: fn() -> Unit) -> Unit = "vitest" "it"; - -extern type Expectation; -extern fn expect(value: a) -> Expectation = "vitest" "expect"; - -extern fn to_be(e: Expectation, v: a) -> Unit = "vitest" "toBe"; -extern fn to_equal(e: Expectation, v: a) -> Unit = "vitest" "toEqual"; -extern fn to_be_truthy(e: Expectation) -> Unit = "vitest" "toBeTruthy"; -extern fn to_be_falsy(e: Expectation) -> Unit = "vitest" "toBeFalsy"; -extern fn to_be_null(e: Expectation) -> Unit = "vitest" "toBeNull"; -extern fn to_be_undefined(e: Expectation) -> Unit = "vitest" "toBeUndefined"; -extern fn to_be_defined(e: Expectation) -> Unit = "vitest" "toBeDefined"; -extern fn to_be_greater_than(e: Expectation, v: a) -> Unit = "vitest" "toBeGreaterThan"; -extern fn to_be_greater_than_or_equal(e: Expectation, v: a) -> Unit = "vitest" "toBeGreaterThanOrEqual"; -extern fn to_be_less_than(e: Expectation, v: a) -> Unit = "vitest" "toBeLessThan"; -extern fn to_be_less_than_or_equal(e: Expectation, v: a) -> Unit = "vitest" "toBeLessThanOrEqual"; -extern fn to_contain(e: Expectation<[a]>, v: a) -> Unit = "vitest" "toContain"; -extern fn to_have_length(e: Expectation<[a]>, n: Int) -> Unit = "vitest" "toHaveLength"; -extern fn to_match(e: Expectation, pattern: String) -> Unit = "vitest" "toMatch"; -extern fn to_match_regex(e: Expectation, pattern: Regex) -> Unit = "vitest" "toMatch"; -extern fn to_throw(e: Expectation a>) -> Unit = "vitest" "toThrow"; -extern fn to_throw_error(e: Expectation a>, msg: String) -> Unit = "vitest" "toThrowError"; - -module Expect { - extern fn not_(e: Expectation) -> Expectation = "vitest" "not"; +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors + +/** + * Vitest Bindings for ReScript + * + * Minimal bindings to the Vitest testing framework. + */ + +@module("vitest") @val +external describe: (string, @uncurry unit => unit) => unit = "describe" + +@module("vitest") @val +external test: (string, @uncurry unit => unit) => unit = "test" + +@module("vitest") @val +external it: (string, @uncurry unit => unit) => unit = "it" + +struct expectation<'a> + +@module("vitest") @val +external expect: 'a => expectation<'a> = "expect" + +@send external toBe: (expectation<'a>, 'a) => unit = "toBe" +@send external toEqual: (expectation<'a>, 'a) => unit = "toEqual" +@send external toBeTruthy: expectation<'a> => unit = "toBeTruthy" +@send external toBeFalsy: expectation<'a> => unit = "toBeFalsy" +@send external toBeNull: expectation<'a> => unit = "toBeNull" +@send external toBeUndefined: expectation<'a> => unit = "toBeUndefined" +@send external toBeDefined: expectation<'a> => unit = "toBeDefined" +@send external toBeGreaterThan: (expectation<'a>, 'a) => unit = "toBeGreaterThan" +@send external toBeGreaterThanOrEqual: (expectation<'a>, 'a) => unit = "toBeGreaterThanOrEqual" +@send external toBeLessThan: (expectation<'a>, 'a) => unit = "toBeLessThan" +@send external toBeLessThanOrEqual: (expectation<'a>, 'a) => unit = "toBeLessThanOrEqual" +@send external toContain: (expectation>, 'a) => unit = "toContain" +@send external toHaveLength: (expectation>, int) => unit = "toHaveLength" +@send external toMatch: (expectation, string) => unit = "toMatch" +@send external toMatchRegex: (expectation, Js.Re.t) => unit = "toMatch" +@send external toThrow: expectation 'a> => unit = "toThrow" +@send external toThrowError: (expectation 'a>, string) => unit = "toThrowError" + +module Expect = { + @send external not_: expectation<'a> => expectation<'a> = "not" } -extern fn before_all(body: fn() -> Unit) -> Unit = "vitest" "beforeAll"; -extern fn after_all(body: fn() -> Unit) -> Unit = "vitest" "afterAll"; -extern fn before_each(body: fn() -> Unit) -> Unit = "vitest" "beforeEach"; -extern fn after_each(body: fn() -> Unit) -> Unit = "vitest" "afterEach"; -extern fn fail() -> Unit = "vitest" "fail"; -extern fn fail_with_message(msg: String) -> Unit = "vitest" "fail"; +@module("vitest") @val +external beforeAll: (@uncurry unit => unit) => unit = "beforeAll" + +@module("vitest") @val +external afterAll: (@uncurry unit => unit) => unit = "afterAll" + +@module("vitest") @val +external beforeEach: (@uncurry unit => unit) => unit = "beforeEach" + +@module("vitest") @val +external afterEach: (@uncurry unit => unit) => unit = "afterEach" + +@module("vitest") @val +external fail: unit => unit = "fail" + +@module("vitest") @val +external failWithMessage: string => unit = "fail" + diff --git a/lol/test/crawlers/Crawler_test.affine b/lol/test/crawlers/Crawler_test.affine index 5f2a742ac..6d18ce30c 100644 --- a/lol/test/crawlers/Crawler_test.affine +++ b/lol/test/crawlers/Crawler_test.affine @@ -1,112 +1,137 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// Crawler module tests. AffineScript port of Crawler_test.res. +// Ported via Harvard Engine (Semantic pass) module Crawler_test; -use Vitest; -use Crawler; - -Vitest.describe("Crawler.Config", fn() { - Vitest.test("has sensible default timeout", fn() { - Vitest.to_be(Vitest.expect(Crawler.Config.default_timeout), 30000) - }); - Vitest.test("has sensible default retries", fn() { - Vitest.to_be(Vitest.expect(Crawler.Config.default_retries), 3) - }); - Vitest.test("has sensible default rate limit", fn() { - Vitest.to_be(Vitest.expect(Crawler.Config.default_rate_limit_ms), 1000) - }); - Vitest.test("makeDefaultHeaders creates valid headers", fn() { - let headers = Crawler.Config.make_default_headers(); - Vitest.to_be(Vitest.expect(option_is_some(dict_get(headers, "User-Agent"))), true) +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors + +/** + * Crawler Module Tests + */ + +open Vitest + +describe("Crawler.Config", () => { + test("has sensible default timeout", () => { + expect(Crawler.Config.defaultTimeout)->toBe(30000) }) -}); - -Vitest.describe("Crawler.Request", fn() { - Vitest.test("make creates request with defaults", fn() { - let req = Crawler.Request.make("https://example.com", Crawler.Types.GET, None, None, None); - Vitest.to_be(Vitest.expect(req.url), "https://example.com"); - Vitest.to_equal(Vitest.expect(req.method), Crawler.Types.GET); - Vitest.to_be(Vitest.expect(req.timeout), Crawler.Config.default_timeout); - Vitest.to_be(Vitest.expect(req.retries), Crawler.Config.default_retries) - }); - Vitest.test("make accepts custom options", fn() { - let req = Crawler.Request.make("https://example.com", Crawler.Types.POST, None, Some(5000), Some(5)); - Vitest.to_equal(Vitest.expect(req.method), Crawler.Types.POST); - Vitest.to_be(Vitest.expect(req.timeout), 5000); - Vitest.to_be(Vitest.expect(req.retries), 5) - }); - Vitest.test("withHeader adds header", fn() { - let req = Crawler.Request.make("https://example.com", Crawler.Types.GET, None, None, None); - let _ = Crawler.Request.with_header(req, "X-Custom", "value"); - Vitest.to_equal(Vitest.expect(dict_get(req.headers, "X-Custom")), Some("value")) - }); - Vitest.test("methodToString converts correctly", fn() { - Vitest.to_be(Vitest.expect(Crawler.Request.method_to_string(Crawler.Types.GET)), "GET"); - Vitest.to_be(Vitest.expect(Crawler.Request.method_to_string(Crawler.Types.POST)), "POST"); - Vitest.to_be(Vitest.expect(Crawler.Request.method_to_string(Crawler.Types.HEAD)), "HEAD") + + test("has sensible default retries", () => { + expect(Crawler.Config.defaultRetries)->toBe(3) }) -}); - -Vitest.describe("Crawler.RateLimiter", fn() { - Vitest.test("make creates limiter with default delay", fn() { - let limiter = Crawler.RateLimiter.make(Crawler.Config.default_rate_limit_ms); - Vitest.to_be(Vitest.expect(limiter.delay_ms), Crawler.Config.default_rate_limit_ms) - }); - Vitest.test("make accepts custom delay", fn() { - let limiter = Crawler.RateLimiter.make(2000); - Vitest.to_be(Vitest.expect(limiter.delay_ms), 2000) - }); - Vitest.test("canProceed returns true for new limiter", fn() { - let limiter = Crawler.RateLimiter.make(Crawler.Config.default_rate_limit_ms); - Vitest.to_be(Vitest.expect(Crawler.RateLimiter.can_proceed(limiter)), true) - }); - Vitest.test("recordRequest updates lastRequest", fn() { - let limiter = Crawler.RateLimiter.make(Crawler.Config.default_rate_limit_ms); - let before = limiter.last_request; - Crawler.RateLimiter.record_request(limiter); - Vitest.to_be(Vitest.expect(limiter.last_request > before), true) + + test("has sensible default rate limit", () => { + expect(Crawler.Config.defaultRateLimitMs)->toBe(1000) + }) + + test("makeDefaultHeaders creates valid headers", () => { + fn headers = Crawler.Config.makeDefaultHeaders() + fn userAgent = Dict.get(headers, "User-Agent") + expect(Option.isSome(userAgent))->toBe(true) + }) +}) + +describe("Crawler.Request", () => { + test("make creates request with defaults", () => { + fn req = Crawler.Request.make(~url="https://example.com", ()) + expect(req.url)->toBe("https://example.com") + expect(req.method)->toEqual(Crawler.Types.GET) + expect(req.timeout)->toBe(Crawler.Config.defaultTimeout) + expect(req.retries)->toBe(Crawler.Config.defaultRetries) + }) + + test("make accepts custom options", () => { + fn req = Crawler.Request.make( + ~url="https://example.com", + ~method=Crawler.Types.POST, + ~timeout=5000, + ~retries=5, + (), + ) + expect(req.method)->toEqual(Crawler.Types.POST) + expect(req.timeout)->toBe(5000) + expect(req.retries)->toBe(5) + }) + + test("withHeader adds header", () => { + fn req = Crawler.Request.make(~url="https://example.com", ()) + fn _ = Crawler.Request.withHeader(req, "X-Custom", "value") + expect(Dict.get(req.headers, "X-Custom"))->toEqual(Some("value")) + }) + + test("methodToString converts correctly", () => { + expect(Crawler.Request.methodToString(Crawler.Types.GET))->toBe("GET") + expect(Crawler.Request.methodToString(Crawler.Types.POST))->toBe("POST") + expect(Crawler.Request.methodToString(Crawler.Types.HEAD))->toBe("HEAD") + }) +}) + +describe("Crawler.RateLimiter", () => { + test("make creates limiter with default delay", () => { + fn limiter = Crawler.RateLimiter.make() + expect(limiter.delayMs)->toBe(Crawler.Config.defaultRateLimitMs) }) -}); - -Vitest.describe("Crawler.RetryPolicy", fn() { - Vitest.describe("calculateDelay", fn() { - Vitest.test("Constant returns same delay", fn() { - let strategy = Crawler.RetryPolicy.Constant(1000); - Vitest.to_be(Vitest.expect(Crawler.RetryPolicy.calculate_delay(strategy, 1)), 1000); - Vitest.to_be(Vitest.expect(Crawler.RetryPolicy.calculate_delay(strategy, 3)), 1000) - }); - Vitest.test("Linear increases linearly", fn() { - let strategy = Crawler.RetryPolicy.Linear(1000); - Vitest.to_be(Vitest.expect(Crawler.RetryPolicy.calculate_delay(strategy, 1)), 1000); - Vitest.to_be(Vitest.expect(Crawler.RetryPolicy.calculate_delay(strategy, 2)), 2000); - Vitest.to_be(Vitest.expect(Crawler.RetryPolicy.calculate_delay(strategy, 3)), 3000) - }); - Vitest.test("Exponential increases exponentially", fn() { - let strategy = Crawler.RetryPolicy.Exponential(1000, 2.0); - Vitest.to_be(Vitest.expect(Crawler.RetryPolicy.calculate_delay(strategy, 1)), 1000); - Vitest.to_be(Vitest.expect(Crawler.RetryPolicy.calculate_delay(strategy, 2)), 2000); - Vitest.to_be(Vitest.expect(Crawler.RetryPolicy.calculate_delay(strategy, 3)), 4000) + + test("make accepts custom delay", () => { + fn limiter = Crawler.RateLimiter.make(~delayMs=2000, ()) + expect(limiter.delayMs)->toBe(2000) + }) + + test("canProceed returns true for new limiter", () => { + fn limiter = Crawler.RateLimiter.make() + expect(Crawler.RateLimiter.canProceed(limiter))->toBe(true) + }) + + test("recordRequest updates lastRequest", () => { + fn limiter = Crawler.RateLimiter.make() + fn before = limiter.lastRequest + Crawler.RateLimiter.recordRequest(limiter) + expect(limiter.lastRequest > before)->toBe(true) + }) +}) + +describe("Crawler.RetryPolicy", () => { + describe("calculateDelay", () => { + test("Constant returns same delay", () => { + fn strategy = Crawler.RetryPolicy.Constant(1000) + expect(Crawler.RetryPolicy.calculateDelay(strategy, 1))->toBe(1000) + expect(Crawler.RetryPolicy.calculateDelay(strategy, 3))->toBe(1000) + }) + + test("Linear increases linearly", () => { + fn strategy = Crawler.RetryPolicy.Linear(1000) + expect(Crawler.RetryPolicy.calculateDelay(strategy, 1))->toBe(1000) + expect(Crawler.RetryPolicy.calculateDelay(strategy, 2))->toBe(2000) + expect(Crawler.RetryPolicy.calculateDelay(strategy, 3))->toBe(3000) + }) + + test("Exponential increases exponentially", () => { + fn strategy = Crawler.RetryPolicy.Exponential(1000, 2.0) + expect(Crawler.RetryPolicy.calculateDelay(strategy, 1))->toBe(1000) + expect(Crawler.RetryPolicy.calculateDelay(strategy, 2))->toBe(2000) + expect(Crawler.RetryPolicy.calculateDelay(strategy, 3))->toBe(4000) + }) + }) + + describe("shouldRetry", () => { + test("returns true when under max", () => { + expect(Crawler.RetryPolicy.shouldRetry(1, 3))->toBe(true) + expect(Crawler.RetryPolicy.shouldRetry(2, 3))->toBe(true) }) - }); - Vitest.describe("shouldRetry", fn() { - Vitest.test("returns true when under max", fn() { - Vitest.to_be(Vitest.expect(Crawler.RetryPolicy.should_retry(1, 3)), true); - Vitest.to_be(Vitest.expect(Crawler.RetryPolicy.should_retry(2, 3)), true) - }); - Vitest.test("returns false when at or over max", fn() { - Vitest.to_be(Vitest.expect(Crawler.RetryPolicy.should_retry(3, 3)), false); - Vitest.to_be(Vitest.expect(Crawler.RetryPolicy.should_retry(4, 3)), false) + + test("returns false when at or over max", () => { + expect(Crawler.RetryPolicy.shouldRetry(3, 3))->toBe(false) + expect(Crawler.RetryPolicy.shouldRetry(4, 3))->toBe(false) }) }) -}); +}) -Vitest.describe("Crawler.Parser", fn() { - Vitest.test("selectorToString formats correctly", fn() { - Vitest.to_be(Vitest.expect(Crawler.Parser.selector_to_string(Crawler.Parser.Css(".verse"))), "css:.verse"); - Vitest.to_be(Vitest.expect(Crawler.Parser.selector_to_string(Crawler.Parser.XPath("//div"))), "xpath://div"); - Vitest.to_be(Vitest.expect(Crawler.Parser.selector_to_string(Crawler.Parser.Regex("\\d+"))), "regex:\\d+") +describe("Crawler.Parser", () => { + test("selectorToString formats correctly", () => { + expect(Crawler.Parser.selectorToString(Crawler.Parser.Css(".verse")))->toBe("css:.verse") + expect(Crawler.Parser.selectorToString(Crawler.Parser.XPath("//div")))->toBe("xpath://div") + expect(Crawler.Parser.selectorToString(Crawler.Parser.Regex("\\d+")))->toBe("regex:\\d+") }) -}); +}) + diff --git a/lol/test/utils/Iso639_test.affine b/lol/test/utils/Iso639_test.affine index 09281a6cb..e98ed2368 100644 --- a/lol/test/utils/Iso639_test.affine +++ b/lol/test/utils/Iso639_test.affine @@ -1,117 +1,153 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// ISO 639 utilities tests. AffineScript port of Iso639_test.res. +// Ported via Harvard Engine (Semantic pass) module Iso639_test; -use Vitest; -use Iso639; - -Vitest.describe("Iso639.Validation", fn() { - Vitest.describe("isValidIso639_1", fn() { - Vitest.test("returns true for valid 2-letter codes", fn() { - Vitest.to_be(Vitest.expect(Iso639.Validation.is_valid_iso639_1("en")), true); - Vitest.to_be(Vitest.expect(Iso639.Validation.is_valid_iso639_1("de")), true); - Vitest.to_be(Vitest.expect(Iso639.Validation.is_valid_iso639_1("zh")), true) - }); - Vitest.test("returns false for invalid codes", fn() { - Vitest.to_be(Vitest.expect(Iso639.Validation.is_valid_iso639_1("eng")), false); - Vitest.to_be(Vitest.expect(Iso639.Validation.is_valid_iso639_1("e")), false); - Vitest.to_be(Vitest.expect(Iso639.Validation.is_valid_iso639_1("EN")), false); - Vitest.to_be(Vitest.expect(Iso639.Validation.is_valid_iso639_1("")), false) +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors + +/** + * ISO 639 Utilities Tests + */ + +open Vitest + +describe("Iso639.Validation", () => { + describe("isValidIso639_1", () => { + test("returns true for valid 2-letter codes", () => { + expect(Iso639.Validation.isValidIso639_1("en"))->toBe(true) + expect(Iso639.Validation.isValidIso639_1("de"))->toBe(true) + expect(Iso639.Validation.isValidIso639_1("zh"))->toBe(true) }) - }); - Vitest.describe("isValidIso639_3", fn() { - Vitest.test("returns true for valid 3-letter codes", fn() { - Vitest.to_be(Vitest.expect(Iso639.Validation.is_valid_iso639_3("eng")), true); - Vitest.to_be(Vitest.expect(Iso639.Validation.is_valid_iso639_3("deu")), true); - Vitest.to_be(Vitest.expect(Iso639.Validation.is_valid_iso639_3("zho")), true) - }); - Vitest.test("returns false for invalid codes", fn() { - Vitest.to_be(Vitest.expect(Iso639.Validation.is_valid_iso639_3("en")), false); - Vitest.to_be(Vitest.expect(Iso639.Validation.is_valid_iso639_3("english")), false); - Vitest.to_be(Vitest.expect(Iso639.Validation.is_valid_iso639_3("ENG")), false); - Vitest.to_be(Vitest.expect(Iso639.Validation.is_valid_iso639_3("")), false) + + test("returns false for invalid codes", () => { + expect(Iso639.Validation.isValidIso639_1("eng"))->toBe(false) + expect(Iso639.Validation.isValidIso639_1("e"))->toBe(false) + expect(Iso639.Validation.isValidIso639_1("EN"))->toBe(false) + expect(Iso639.Validation.isValidIso639_1(""))->toBe(false) }) - }); - Vitest.describe("detectCodeType", fn() { - Vitest.test("detects ISO 639-1 codes", fn() { - Vitest.to_equal(Vitest.expect(Iso639.Validation.detect_code_type("en")), Some(Iso639.Types.C_Iso639_1)) - }); - Vitest.test("detects ISO 639-3 codes", fn() { - Vitest.to_equal(Vitest.expect(Iso639.Validation.detect_code_type("eng")), Some(Iso639.Types.C_Iso639_3)) - }); - Vitest.test("returns None for invalid codes", fn() { - Vitest.to_equal(Vitest.expect(Iso639.Validation.detect_code_type("english")), None); - Vitest.to_equal(Vitest.expect(Iso639.Validation.detect_code_type("")), None) + }) + + describe("isValidIso639_3", () => { + test("returns true for valid 3-letter codes", () => { + expect(Iso639.Validation.isValidIso639_3("eng"))->toBe(true) + expect(Iso639.Validation.isValidIso639_3("deu"))->toBe(true) + expect(Iso639.Validation.isValidIso639_3("zho"))->toBe(true) }) - }); - Vitest.describe("normalize", fn() { - Vitest.test("converts to lowercase", fn() { - Vitest.to_be(Vitest.expect(Iso639.Validation.normalize("ENG")), "eng"); - Vitest.to_be(Vitest.expect(Iso639.Validation.normalize("EN")), "en") - }); - Vitest.test("trims whitespace", fn() { - Vitest.to_be(Vitest.expect(Iso639.Validation.normalize(" eng ")), "eng") + + test("returns false for invalid codes", () => { + expect(Iso639.Validation.isValidIso639_3("en"))->toBe(false) + expect(Iso639.Validation.isValidIso639_3("english"))->toBe(false) + expect(Iso639.Validation.isValidIso639_3("ENG"))->toBe(false) + expect(Iso639.Validation.isValidIso639_3(""))->toBe(false) }) }) -}); - -Vitest.describe("Iso639.SpecialCodes", fn() { - Vitest.test("isSpecial returns true for special codes", fn() { - Vitest.to_be(Vitest.expect(Iso639.SpecialCodes.is_special("und")), true); - Vitest.to_be(Vitest.expect(Iso639.SpecialCodes.is_special("mul")), true); - Vitest.to_be(Vitest.expect(Iso639.SpecialCodes.is_special("mis")), true); - Vitest.to_be(Vitest.expect(Iso639.SpecialCodes.is_special("zxx")), true) - }); - Vitest.test("isSpecial returns false for regular codes", fn() { - Vitest.to_be(Vitest.expect(Iso639.SpecialCodes.is_special("eng")), false); - Vitest.to_be(Vitest.expect(Iso639.SpecialCodes.is_special("deu")), false) + + describe("detectCodeType", () => { + test("detects ISO 639-1 codes", () => { + expect(Iso639.Validation.detectCodeType("en"))->toEqual(Some(Iso639.Types.Iso639_1)) + }) + + test("detects ISO 639-3 codes", () => { + expect(Iso639.Validation.detectCodeType("eng"))->toEqual(Some(Iso639.Types.Iso639_3)) + }) + + test("returns None for invalid codes", () => { + expect(Iso639.Validation.detectCodeType("english"))->toEqual(None) + expect(Iso639.Validation.detectCodeType(""))->toEqual(None) + }) }) -}); - -Vitest.describe("Iso639.Conversion", fn() { - Vitest.describe("toIso639_3", fn() { - Vitest.test("converts ISO 639-1 to ISO 639-3", fn() { - Vitest.to_equal(Vitest.expect(Iso639.Conversion.to_iso639_3("en")), Some("eng")); - Vitest.to_equal(Vitest.expect(Iso639.Conversion.to_iso639_3("de")), Some("deu")); - Vitest.to_equal(Vitest.expect(Iso639.Conversion.to_iso639_3("fr")), Some("fra")) - }); - Vitest.test("passes through valid ISO 639-3 codes", fn() { - Vitest.to_equal(Vitest.expect(Iso639.Conversion.to_iso639_3("eng")), Some("eng")) - }); - Vitest.test("returns None for unknown codes", fn() { - Vitest.to_equal(Vitest.expect(Iso639.Conversion.to_iso639_3("xx")), None) + + describe("normalize", () => { + test("converts to lowercase", () => { + expect(Iso639.Validation.normalize("ENG"))->toBe("eng") + expect(Iso639.Validation.normalize("EN"))->toBe("en") }) + + test("trims whitespace", () => { + expect(Iso639.Validation.normalize(" eng "))->toBe("eng") + }) + }) +}) + +describe("Iso639.SpecialCodes", () => { + test("isSpecial returns true for special codes", () => { + expect(Iso639.SpecialCodes.isSpecial("und"))->toBe(true) + expect(Iso639.SpecialCodes.isSpecial("mul"))->toBe(true) + expect(Iso639.SpecialCodes.isSpecial("mis"))->toBe(true) + expect(Iso639.SpecialCodes.isSpecial("zxx"))->toBe(true) }) -}); - -Vitest.describe("Iso639.Registry", fn() { - Vitest.test("empty creates empty registry", fn() { - let registry = Iso639.Registry.empty(); - Vitest.to_be(Vitest.expect(Iso639.Registry.count(registry)), 0) - }); - Vitest.test("add and findByCode works", fn() { - let registry = Iso639.Registry.empty(); - let entry = Iso639.Types.LanguageEntry { - iso639_3: "eng", iso639_2b: Some("eng"), iso639_2t: Some("eng"), - iso639_1: Some("en"), scope: Iso639.Types.Individual, - type_: Iso639.Types.Living, name: "English", comment: None, - }; - Iso639.Registry.add(registry, entry); - Vitest.to_equal(Vitest.expect(Iso639.Registry.find_by_code(registry, "eng")), Some(entry)); - Vitest.to_equal(Vitest.expect(Iso639.Registry.find_by_code(registry, "en")), Some(entry)) - }); - Vitest.test("findByName works", fn() { - let registry = Iso639.Registry.empty(); - let entry = Iso639.Types.LanguageEntry { - iso639_3: "deu", iso639_2b: Some("ger"), iso639_2t: Some("deu"), - iso639_1: Some("de"), scope: Iso639.Types.Individual, - type_: Iso639.Types.Living, name: "German", comment: None, - }; - Iso639.Registry.add(registry, entry); - Vitest.to_equal(Vitest.expect(Iso639.Registry.find_by_name(registry, "German")), Some(entry)); - Vitest.to_equal(Vitest.expect(Iso639.Registry.find_by_name(registry, "german")), Some(entry)) + + test("isSpecial returns false for regular codes", () => { + expect(Iso639.SpecialCodes.isSpecial("eng"))->toBe(false) + expect(Iso639.SpecialCodes.isSpecial("deu"))->toBe(false) }) -}); +}) + +describe("Iso639.Conversion", () => { + describe("toIso639_3", () => { + test("converts ISO 639-1 to ISO 639-3", () => { + expect(Iso639.Conversion.toIso639_3("en"))->toEqual(Some("eng")) + expect(Iso639.Conversion.toIso639_3("de"))->toEqual(Some("deu")) + expect(Iso639.Conversion.toIso639_3("fr"))->toEqual(Some("fra")) + }) + + test("passes through valid ISO 639-3 codes", () => { + expect(Iso639.Conversion.toIso639_3("eng"))->toEqual(Some("eng")) + }) + + test("returns None for unknown codes", () => { + expect(Iso639.Conversion.toIso639_3("xx"))->toEqual(None) + }) + }) +}) + +describe("Iso639.Registry", () => { + test("empty creates empty registry", () => { + fn registry = Iso639.Registry.empty() + expect(Iso639.Registry.count(registry))->toBe(0) + }) + + test("add and findByCode works", () => { + fn registry = Iso639.Registry.empty() + fn entry: Iso639.Types.languageEntry = { + iso639_3: "eng", + iso639_2b: Some("eng"), + iso639_2t: Some("eng"), + iso639_1: Some("en"), + scope: Iso639.Types.Individual, + struct_: Iso639.Types.Living, + name: "English", + comment: None, + } + Iso639.Registry.add(registry, entry) + + fn found = Iso639.Registry.findByCode(registry, "eng") + expect(found)->toEqual(Some(entry)) + + fn foundByIso1 = Iso639.Registry.findByCode(registry, "en") + expect(foundByIso1)->toEqual(Some(entry)) + }) + + test("findByName works", () => { + fn registry = Iso639.Registry.empty() + fn entry: Iso639.Types.languageEntry = { + iso639_3: "deu", + iso639_2b: Some("ger"), + iso639_2t: Some("deu"), + iso639_1: Some("de"), + scope: Iso639.Types.Individual, + struct_: Iso639.Types.Living, + name: "German", + comment: None, + } + Iso639.Registry.add(registry, entry) + + fn found = Iso639.Registry.findByName(registry, "German") + expect(found)->toEqual(Some(entry)) + + fn foundLower = Iso639.Registry.findByName(registry, "german") + expect(foundLower)->toEqual(Some(entry)) + }) +}) + diff --git a/lol/test/utils/Statistics_test.affine b/lol/test/utils/Statistics_test.affine index 259d47b76..4e6b5964f 100644 --- a/lol/test/utils/Statistics_test.affine +++ b/lol/test/utils/Statistics_test.affine @@ -1,183 +1,242 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell and Contributors -// -// Statistics utilities tests. AffineScript port of Statistics_test.res. +// Ported via Harvard Engine (Semantic pass) module Statistics_test; -use Vitest; -use Statistics; +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2024-2026 Jonathan D.A. Jewell and Contributors -extern fn math_abs(x: Float) -> Float = "Math" "abs"; -extern fn float_is_nan(x: Float) -> Bool = "Float" "isNaN"; +/** + * Statistics Utilities Tests + */ -fn approximately(a: Float, b: Float) -> Bool { - math_abs(a -. b) < 0.0001 +open Vitest + +fn approximately = (a, b, ~tolerance=0.0001, ()) => { + Math.abs(a -. b) < tolerance } -Vitest.describe("Statistics.Basic", fn() { - Vitest.describe("sum", fn() { - Vitest.test("sums array of floats", fn() { - Vitest.to_be(Vitest.expect(Statistics.Basic.sum([1.0, 2.0, 3.0, 4.0])), 10.0) - }); - Vitest.test("returns 0 for empty array", fn() { - Vitest.to_be(Vitest.expect(Statistics.Basic.sum([])), 0.0) - }) - }); - Vitest.describe("mean", fn() { - Vitest.test("calculates mean correctly", fn() { - Vitest.to_be(Vitest.expect(Statistics.Basic.mean([1.0, 2.0, 3.0, 4.0, 5.0])), 3.0) - }); - Vitest.test("returns 0 for empty array", fn() { - Vitest.to_be(Vitest.expect(Statistics.Basic.mean([])), 0.0) - }) - }); - Vitest.describe("variance", fn() { - Vitest.test("calculates variance correctly", fn() { - let result = Statistics.Basic.variance([2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]); - Vitest.to_be(Vitest.expect(approximately(result, 4.0)), true) - }); - Vitest.test("returns 0 for single element", fn() { - Vitest.to_be(Vitest.expect(Statistics.Basic.variance([5.0])), 0.0) - }); - Vitest.test("returns 0 for empty array", fn() { - Vitest.to_be(Vitest.expect(Statistics.Basic.variance([])), 0.0) - }) - }); - Vitest.describe("standardDeviation", fn() { - Vitest.test("calculates standard deviation correctly", fn() { - let result = Statistics.Basic.standard_deviation([2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]); - Vitest.to_be(Vitest.expect(approximately(result, 2.0)), true) - }) - }); - Vitest.describe("min and max", fn() { - Vitest.test("min returns minimum value", fn() { - Vitest.to_equal(Vitest.expect(Statistics.Basic.min([3.0, 1.0, 4.0, 1.0, 5.0])), Some(1.0)) - }); - Vitest.test("max returns maximum value", fn() { - Vitest.to_equal(Vitest.expect(Statistics.Basic.max([3.0, 1.0, 4.0, 1.0, 5.0])), Some(5.0)) - }); - Vitest.test("min returns None for empty array", fn() { - Vitest.to_equal(Vitest.expect(Statistics.Basic.min([])), None) - }); - Vitest.test("max returns None for empty array", fn() { - Vitest.to_equal(Vitest.expect(Statistics.Basic.max([])), None) +describe("Statistics.Basic", () => { + describe("sum", () => { + test("sums array of floats", () => { + expect(Statistics.Basic.sum([1.0, 2.0, 3.0, 4.0]))->toBe(10.0) + }) + + test("returns 0 for empty array", () => { + expect(Statistics.Basic.sum([]))->toBe(0.0) + }) + }) + + describe("mean", () => { + test("calculates mean correctly", () => { + expect(Statistics.Basic.mean([1.0, 2.0, 3.0, 4.0, 5.0]))->toBe(3.0) + }) + + test("returns 0 for empty array", () => { + expect(Statistics.Basic.mean([]))->toBe(0.0) + }) + }) + + describe("variance", () => { + test("calculates variance correctly", () => { + fn result = Statistics.Basic.variance([2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]) + expect(approximately(result, 4.0, ()))->toBe(true) + }) + + test("returns 0 for single element", () => { + expect(Statistics.Basic.variance([5.0]))->toBe(0.0) + }) + + test("returns 0 for empty array", () => { + expect(Statistics.Basic.variance([]))->toBe(0.0) + }) + }) + + describe("standardDeviation", () => { + test("calculates standard deviation correctly", () => { + fn result = Statistics.Basic.standardDeviation([2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]) + expect(approximately(result, 2.0, ()))->toBe(true) + }) + }) + + describe("min and max", () => { + test("min returns minimum value", () => { + expect(Statistics.Basic.min([3.0, 1.0, 4.0, 1.0, 5.0]))->toEqual(Some(1.0)) + }) + + test("max returns maximum value", () => { + expect(Statistics.Basic.max([3.0, 1.0, 4.0, 1.0, 5.0]))->toEqual(Some(5.0)) + }) + + test("min returns None for empty array", () => { + expect(Statistics.Basic.min([]))->toEqual(None) + }) + + test("max returns None for empty array", () => { + expect(Statistics.Basic.max([]))->toEqual(None) + }) + }) +}) + +describe("Statistics.Information", () => { + describe("entropy", () => { + test("calculates entropy for uniform distribution", () => { + // Uniform distribution over 4 outcomes: H = log2(4) = 2 + fn dist = [0.25, 0.25, 0.25, 0.25] + fn result = Statistics.Information.entropy(dist) + expect(approximately(result, 2.0, ()))->toBe(true) + }) + + test("returns 0 for deterministic distribution", () => { + fn dist = [1.0, 0.0, 0.0, 0.0] + fn result = Statistics.Information.entropy(dist) + expect(approximately(result, 0.0, ()))->toBe(true) + }) + + test("handles binary distribution correctly", () => { + // Fair coin: H = 1 bit + fn dist = [0.5, 0.5] + fn result = Statistics.Information.entropy(dist) + expect(approximately(result, 1.0, ()))->toBe(true) + }) + }) + + describe("klDivergence", () => { + test("returns 0 for identical distributions", () => { + fn p = [0.25, 0.25, 0.25, 0.25] + fn result = Statistics.Information.klDivergence(p, p) + expect(approximately(result, 0.0, ()))->toBe(true) + }) + + test("is asymmetric", () => { + fn p = [0.5, 0.5] + fn q = [0.9, 0.1] + fn kl_pq = Statistics.Information.klDivergence(p, q) + fn kl_qp = Statistics.Information.klDivergence(q, p) + expect(kl_pq != kl_qp)->toBe(true) + }) + + test("returns NaN for mismatched lengths", () => { + fn p = [0.5, 0.5] + fn q = [0.33, 0.33, 0.34] + fn result = Statistics.Information.klDivergence(p, q) + expect(Float.isNaN(result))->toBe(true) }) }) -}); - -Vitest.describe("Statistics.Information", fn() { - Vitest.describe("entropy", fn() { - Vitest.test("calculates entropy for uniform distribution", fn() { - let result = Statistics.Information.entropy([0.25, 0.25, 0.25, 0.25]); - Vitest.to_be(Vitest.expect(approximately(result, 2.0)), true) - }); - Vitest.test("returns 0 for deterministic distribution", fn() { - let result = Statistics.Information.entropy([1.0, 0.0, 0.0, 0.0]); - Vitest.to_be(Vitest.expect(approximately(result, 0.0)), true) - }); - Vitest.test("handles binary distribution correctly", fn() { - let result = Statistics.Information.entropy([0.5, 0.5]); - Vitest.to_be(Vitest.expect(approximately(result, 1.0)), true) - }) - }); - Vitest.describe("klDivergence", fn() { - Vitest.test("returns 0 for identical distributions", fn() { - let p = [0.25, 0.25, 0.25, 0.25]; - Vitest.to_be(Vitest.expect(approximately(Statistics.Information.kl_divergence(p, p), 0.0)), true) - }); - Vitest.test("is asymmetric", fn() { - let p = [0.5, 0.5]; - let q = [0.9, 0.1]; - Vitest.to_be(Vitest.expect(Statistics.Information.kl_divergence(p, q) != Statistics.Information.kl_divergence(q, p)), true) - }); - Vitest.test("returns NaN for mismatched lengths", fn() { - let result = Statistics.Information.kl_divergence([0.5, 0.5], [0.33, 0.33, 0.34]); - Vitest.to_be(Vitest.expect(float_is_nan(result)), true) - }) - }); - Vitest.describe("symmetricKL", fn() { - Vitest.test("is symmetric", fn() { - let p = [0.5, 0.5]; - let q = [0.9, 0.1]; - Vitest.to_be(Vitest.expect(approximately(Statistics.Information.symmetric_kl(p, q), Statistics.Information.symmetric_kl(q, p))), true) - }) - }); - Vitest.describe("jensenShannon", fn() { - Vitest.test("is symmetric", fn() { - let p = [0.5, 0.5]; - let q = [0.9, 0.1]; - Vitest.to_be(Vitest.expect(approximately(Statistics.Information.jensen_shannon(p, q), Statistics.Information.jensen_shannon(q, p))), true) - }); - Vitest.test("returns 0 for identical distributions", fn() { - let p = [0.25, 0.25, 0.25, 0.25]; - Vitest.to_be(Vitest.expect(approximately(Statistics.Information.jensen_shannon(p, p), 0.0)), true) - }); - Vitest.test("is bounded between 0 and 1", fn() { - let result = Statistics.Information.jensen_shannon([1.0, 0.0], [0.0, 1.0]); - Vitest.to_be(Vitest.expect(result >= 0.0 && result <= 1.0), true) + + describe("symmetricKL", () => { + test("is symmetric", () => { + fn p = [0.5, 0.5] + fn q = [0.9, 0.1] + fn skl_pq = Statistics.Information.symmetricKL(p, q) + fn skl_qp = Statistics.Information.symmetricKL(q, p) + expect(approximately(skl_pq, skl_qp, ()))->toBe(true) }) }) -}); - -Vitest.describe("Statistics.Distance", fn() { - Vitest.describe("euclidean", fn() { - Vitest.test("calculates euclidean distance correctly", fn() { - Vitest.to_be(Vitest.expect(Statistics.Distance.euclidean([0.0, 0.0], [3.0, 4.0])), 5.0) - }); - Vitest.test("returns 0 for identical vectors", fn() { - let a = [1.0, 2.0, 3.0]; - Vitest.to_be(Vitest.expect(Statistics.Distance.euclidean(a, a)), 0.0) - }) - }); - Vitest.describe("cosine", fn() { - Vitest.test("returns 0 for identical vectors", fn() { - let a = [1.0, 2.0, 3.0]; - Vitest.to_be(Vitest.expect(approximately(Statistics.Distance.cosine(a, a), 0.0)), true) - }); - Vitest.test("returns 1 for orthogonal vectors", fn() { - Vitest.to_be(Vitest.expect(approximately(Statistics.Distance.cosine([1.0, 0.0], [0.0, 1.0]), 1.0)), true) - }) - }); - Vitest.describe("jaccard", fn() { - Vitest.test("returns 0 for identical sets", fn() { - let a = [1.0, 1.0, 0.0]; - Vitest.to_be(Vitest.expect(Statistics.Distance.jaccard(a, a)), 0.0) - }); - Vitest.test("returns 1 for disjoint sets", fn() { - Vitest.to_be(Vitest.expect(Statistics.Distance.jaccard([1.0, 0.0, 0.0], [0.0, 1.0, 1.0])), 1.0) + + describe("jensenShannon", () => { + test("is symmetric", () => { + fn p = [0.5, 0.5] + fn q = [0.9, 0.1] + fn js_pq = Statistics.Information.jensenShannon(p, q) + fn js_qp = Statistics.Information.jensenShannon(q, p) + expect(approximately(js_pq, js_qp, ()))->toBe(true) + }) + + test("returns 0 for identical distributions", () => { + fn p = [0.25, 0.25, 0.25, 0.25] + fn result = Statistics.Information.jensenShannon(p, p) + expect(approximately(result, 0.0, ()))->toBe(true) + }) + + test("is bounded between 0 and 1", () => { + fn p = [1.0, 0.0] + fn q = [0.0, 1.0] + fn result = Statistics.Information.jensenShannon(p, q) + expect(result >= 0.0 && result <= 1.0)->toBe(true) }) }) -}); - -Vitest.describe("Statistics.Normalization", fn() { - Vitest.describe("normalize", fn() { - Vitest.test("normalizes to sum to 1", fn() { - let result = Statistics.Normalization.normalize([1.0, 2.0, 3.0, 4.0]); - Vitest.to_be(Vitest.expect(approximately(Statistics.Basic.sum(result), 1.0)), true) - }); - Vitest.test("preserves proportions", fn() { - let result = Statistics.Normalization.normalize([1.0, 3.0]); - Vitest.to_be(Vitest.expect(approximately(result[0], 0.25)), true); - Vitest.to_be(Vitest.expect(approximately(result[1], 0.75)), true) - }) - }); - Vitest.describe("minMaxNormalize", fn() { - Vitest.test("scales to [0, 1] range", fn() { - let result = Statistics.Normalization.min_max_normalize([10.0, 20.0, 30.0]); - Vitest.to_be(Vitest.expect(result[0]), 0.0); - Vitest.to_be(Vitest.expect(result[1]), 0.5); - Vitest.to_be(Vitest.expect(result[2]), 1.0) - }) - }); - Vitest.describe("zScoreNormalize", fn() { - Vitest.test("results in mean of 0", fn() { - let result = Statistics.Normalization.z_score_normalize([1.0, 2.0, 3.0, 4.0, 5.0]); - Vitest.to_be(Vitest.expect(approximately(Statistics.Basic.mean(result), 0.0)), true) - }); - Vitest.test("results in standard deviation of 1", fn() { - let result = Statistics.Normalization.z_score_normalize([1.0, 2.0, 3.0, 4.0, 5.0]); - Vitest.to_be(Vitest.expect(approximately(Statistics.Basic.standard_deviation(result), 1.0)), true) +}) + +describe("Statistics.Distance", () => { + describe("euclidean", () => { + test("calculates euclidean distance correctly", () => { + fn a = [0.0, 0.0] + fn b = [3.0, 4.0] + expect(Statistics.Distance.euclidean(a, b))->toBe(5.0) + }) + + test("returns 0 for identical vectors", () => { + fn a = [1.0, 2.0, 3.0] + expect(Statistics.Distance.euclidean(a, a))->toBe(0.0) }) }) -}); + + describe("cosine", () => { + test("returns 0 for identical vectors", () => { + fn a = [1.0, 2.0, 3.0] + fn result = Statistics.Distance.cosine(a, a) + expect(approximately(result, 0.0, ()))->toBe(true) + }) + + test("returns 1 for orthogonal vectors", () => { + fn a = [1.0, 0.0] + fn b = [0.0, 1.0] + fn result = Statistics.Distance.cosine(a, b) + expect(approximately(result, 1.0, ()))->toBe(true) + }) + }) + + describe("jaccard", () => { + test("returns 0 for identical sets", () => { + fn a = [1.0, 1.0, 0.0] + expect(Statistics.Distance.jaccard(a, a))->toBe(0.0) + }) + + test("returns 1 for disjoint sets", () => { + fn a = [1.0, 0.0, 0.0] + fn b = [0.0, 1.0, 1.0] + expect(Statistics.Distance.jaccard(a, b))->toBe(1.0) + }) + }) +}) + +describe("Statistics.Normalization", () => { + describe("normalize", () => { + test("normalizes to sum to 1", () => { + fn result = Statistics.Normalization.normalize([1.0, 2.0, 3.0, 4.0]) + fn sum = Statistics.Basic.sum(result) + expect(approximately(sum, 1.0, ()))->toBe(true) + }) + + test("preserves proportions", () => { + fn result = Statistics.Normalization.normalize([1.0, 3.0]) + expect(approximately(Array.getUnsafe(result, 0), 0.25, ()))->toBe(true) + expect(approximately(Array.getUnsafe(result, 1), 0.75, ()))->toBe(true) + }) + }) + + describe("minMaxNormalize", () => { + test("scales to [0, 1] range", () => { + fn result = Statistics.Normalization.minMaxNormalize([10.0, 20.0, 30.0]) + expect(Array.getUnsafe(result, 0))->toBe(0.0) + expect(Array.getUnsafe(result, 1))->toBe(0.5) + expect(Array.getUnsafe(result, 2))->toBe(1.0) + }) + }) + + describe("zScoreNormalize", () => { + test("results in mean of 0", () => { + fn result = Statistics.Normalization.zScoreNormalize([1.0, 2.0, 3.0, 4.0, 5.0]) + fn mean = Statistics.Basic.mean(result) + expect(approximately(mean, 0.0, ()))->toBe(true) + }) + + test("results in standard deviation of 1", () => { + fn result = Statistics.Normalization.zScoreNormalize([1.0, 2.0, 3.0, 4.0, 5.0]) + fn sd = Statistics.Basic.standardDeviation(result) + expect(approximately(sd, 1.0, ()))->toBe(true) + }) + }) +}) + diff --git a/panll-panels/src/ComplianceMonitor.affine b/panll-panels/src/ComplianceMonitor.affine index 7e173eee8..dfbba9ebc 100644 --- a/panll-panels/src/ComplianceMonitor.affine +++ b/panll-panels/src/ComplianceMonitor.affine @@ -1,129 +1,98 @@ // SPDX-License-Identifier: MPL-2.0 -// Compliance Monitor panel. AffineScript port of ComplianceMonitor.res. +// Ported via Harvard Engine (Semantic pass) module ComplianceMonitor; -use VcldbClient; +// SPDX-License-Identifier: PMPL-1.0-or-later +// Compliance Monitor panel — Hypatia findings heatmap + trend. -extern type ReactNode; -extern fn react_use_reducer(reduce: fn(State, Action) -> State, initial: State) -> (State, fn(Action) -> Unit) = "react" "useReducer"; -extern fn react_use_effect0(body: fn() -> Option Unit>) -> Unit = "react" "useEffect0"; -extern fn react_string(s: String) -> ReactNode = "react" "string"; -extern fn react_int(n: Int) -> ReactNode = "react" "int"; -extern fn react_array(xs: [ReactNode]) -> ReactNode = "react" "array"; -extern fn react_null() -> ReactNode = "react" "null"; -extern fn h(tag: String, props: Json, children: [ReactNode]) -> ReactNode = "react" "h"; -extern fn set_interval(cb: fn() -> Unit, ms: Int) -> Int = "global" "setInterval"; -extern fn clear_interval(id: Int) -> Unit = "global" "clearInterval"; -extern fn json_decode_string(j: Json) -> Option = "json" "decodeString"; -extern fn json_decode_number(j: Json) -> Option = "json" "decodeNumber"; - -pub type Finding = { - repo: String, - severity: String, - count: Int, +struct finding { { + repo: string, + severity: string, + count: int, } -pub type State = { - loading: Bool, - findings: [Finding], - error: Option, +struct state { { + loading: bool, + findings: array, + error: option, } -pub type Action = +struct action { | LoadStarted - | FindingsLoaded([Finding]) - | LoadFailed(String) + | FindingsLoaded(array) + | LoadFailed(string) -pub let initial = State { loading: false, findings: [], error: None }; +fn initial: state = {loading: false, findings: [], error: None} -pub fn reduce(state: State, action: Action) -> State { - match action { - LoadStarted => State { ...state, loading: true, error: None }, - FindingsLoaded(rows) => State { ...state, loading: false, findings: rows }, - LoadFailed(msg) => State { ...state, loading: false, error: Some(msg) }, +fn reduce = (state: state, action: action): state => + switch action { + | LoadStarted => {...state, loading: true, error: None} + | FindingsLoaded(rows) => {...state, loading: false, findings: rows} + | LoadFailed(msg) => {...state, loading: false, error: Some(msg)} } -} -fn pick_str(sem: Dict, key: String) -> String { - match dict_get(sem, key) { - Some(j) => match json_decode_string(j) { Some(s) => s, None => "?" }, - None => "?", +fn refresh = async (dispatch: action => unit) => { + dispatch(LoadStarted) + switch await VcldbClient.openFindings() { + | Ok(result) => + fn findings = result.rows->Belt.Array.map(row => { + fn repo = row["semantic"]->Js.Dict.get("repo") + ->Belt.Option.flatMap(Js.Json.decodeString) + ->Belt.Option.getWithDefault("?") + fn sev = row["semantic"]->Js.Dict.get("severity") + ->Belt.Option.flatMap(Js.Json.decodeString) + ->Belt.Option.getWithDefault("?") + fn count = row["semantic"]->Js.Dict.get("count") + ->Belt.Option.flatMap(Js.Json.decodeNumber) + ->Belt.Option.mapWithDefault(0, f => f->Belt.Float.toInt) + {repo, severity: sev, count} + }) + dispatch(FindingsLoaded(findings)) + | Error(msg) => dispatch(LoadFailed(msg)) } } -pub fn refresh(dispatch: fn(Action) -> Unit) -> Effect[Async] Unit { - dispatch(LoadStarted); - match await VcldbClient.open_findings() { - Ok(result) => { - let findings = []; - let i = 0; - while i < len(result.rows) { - let sem = result.rows[i].semantic; - let count = match dict_get(sem, "count") { - Some(j) => match json_decode_number(j) { Some(f) => float_to_int(f), None => 0 }, - None => 0, - }; - findings = findings ++ [Finding { - repo: pick_str(sem, "repo"), - severity: pick_str(sem, "severity"), - count: count, - }]; - i = i + 1; - } - dispatch(FindingsLoaded(findings)) - } - Err(msg) => dispatch(LoadFailed(msg)), +fn severityColour = (sev: string): string => + switch sev { + | "critical" => "#d00" + | "high" => "#f60" + | "medium" => "#fc0" + | "low" => "#6c0" + | _ => "#ccc" } -} -pub fn severity_colour(sev: String) -> String { - match sev { - "critical" => "#d00", - "high" => "#f60", - "medium" => "#fc0", - "low" => "#6c0", - _ => "#ccc", - } +@react.component +fn make = () => { + fn (state, dispatch) = React.useReducer(reduce, initial) + + React.useEffect0(() => { + refresh(dispatch)->ignore + fn id = Js.Global.setInterval(() => refresh(dispatch)->ignore, 30000) + Some(() => Js.Global.clearInterval(id)) + }) + +
+

{React.string("Compliance Monitor")}

+ {switch state.error { + | Some(msg) =>
{React.string(msg)}
+ | None => React.null + }} + {state.loading + ?
{React.string("Loading...")}
+ :
+ {state.findings + ->Belt.Array.map(f => +
+
{React.string(f.repo)}
+
{React.int(f.count)}
+
+ ) + ->React.array} +
} +
} -pub fn make() -> ReactNode { - let (state, dispatch) = react_use_reducer(reduce, initial); - - react_use_effect0(fn() { - refresh(dispatch); - let id = set_interval(fn() { refresh(dispatch); }, 30000); - Some(fn() { clear_interval(id); }) - }); - - let error_node = match state.error { - Some(msg) => h("div", json_object([("className", json_string("error"))]), [react_string(msg)]), - None => react_null(), - }; - - let body = if state.loading { - h("div", json_object([("className", json_string("spinner"))]), [react_string("Loading...")]) - } else { - let cells = []; - let i = 0; - while i < len(state.findings) { - let f = state.findings[i]; - cells = cells ++ [h("div", json_object([ - ("key", json_string(f.repo ++ "-" ++ f.severity)), - ("className", json_string("cell")), - ("style", json_object([("backgroundColor", json_string(severity_colour(f.severity)))])), - ]), [ - h("div", json_object([("className", json_string("cell-repo"))]), [react_string(f.repo)]), - h("div", json_object([("className", json_string("cell-count"))]), [react_int(f.count)]), - ])]; - i = i + 1; - } - h("div", json_object([("className", json_string("heatmap"))]), [react_array(cells)]) - }; - - h("div", json_object([("className", json_string("panel panel-compliance-monitor"))]), [ - h("h2", json_object([]), [react_string("Compliance Monitor")]), - error_node, - body, - ]) -} diff --git a/panll-panels/src/CrgDashboard.affine b/panll-panels/src/CrgDashboard.affine index 534cf4513..54c37ded1 100644 --- a/panll-panels/src/CrgDashboard.affine +++ b/panll-panels/src/CrgDashboard.affine @@ -1,110 +1,85 @@ // SPDX-License-Identifier: MPL-2.0 -// CRG Dashboard panel. AffineScript port of CrgDashboard.res. +// Ported via Harvard Engine (Semantic pass) module CrgDashboard; -use VcldbClient; +// SPDX-License-Identifier: PMPL-1.0-or-later +// CRG Dashboard panel — grade distribution, transitions, promotion queue. -extern type ReactNode; -extern fn react_use_reducer(reduce: fn(State, Action) -> State, initial: State) -> (State, fn(Action) -> Unit) = "react" "useReducer"; -extern fn react_use_effect0(body: fn() -> Option Unit>) -> Unit = "react" "useEffect0"; -extern fn react_string(s: String) -> ReactNode = "react" "string"; -extern fn react_int(n: Int) -> ReactNode = "react" "int"; -extern fn react_array(xs: [ReactNode]) -> ReactNode = "react" "array"; -extern fn react_null() -> ReactNode = "react" "null"; -extern fn h(tag: String, props: Json, children: [ReactNode]) -> ReactNode = "react" "h"; -extern fn set_interval(cb: fn() -> Unit, ms: Int) -> Int = "global" "setInterval"; -extern fn clear_interval(id: Int) -> Unit = "global" "clearInterval"; -extern fn json_decode_string(j: Json) -> Option = "json" "decodeString"; -extern fn json_decode_number(j: Json) -> Option = "json" "decodeNumber"; - -pub type State = { - loading: Bool, - distribution: [(String, Int)], - error: Option, +struct state { { + loading: bool, + distribution: array<(string, int)>, // (grade, count) + error: option, } -pub type Action = +struct action { | LoadStarted - | DistributionLoaded([(String, Int)]) - | LoadFailed(String) + | DistributionLoaded(array<(string, int)>) + | LoadFailed(string) -pub let initial = State { loading: false, distribution: [], error: None }; +fn initial: state = { + loading: false, + distribution: [], + error: None, +} -pub fn reduce(state: State, action: Action) -> State { - match action { - LoadStarted => State { ...state, loading: true, error: None }, - DistributionLoaded(rows) => State { ...state, loading: false, distribution: rows }, - LoadFailed(msg) => State { ...state, loading: false, error: Some(msg) }, +fn reduce = (state: state, action: action): state => + switch action { + | LoadStarted => {...state, loading: true, error: None} + | DistributionLoaded(rows) => {...state, loading: false, distribution: rows} + | LoadFailed(msg) => {...state, loading: false, error: Some(msg)} } -} -pub fn refresh(dispatch: fn(Action) -> Unit) -> Effect[Async] Unit { - dispatch(LoadStarted); - match await VcldbClient.grade_distribution() { - Ok(result) => { - let rows = []; - let i = 0; - while i < len(result.rows) { - let sem = result.rows[i].semantic; - let g = match dict_get(sem, "grade") { - Some(j) => match json_decode_string(j) { Some(s) => s, None => "?" }, - None => "?", - }; - let c = match dict_get(sem, "count") { - Some(j) => match json_decode_number(j) { Some(f) => float_to_int(f), None => 0 }, - None => 0, - }; - rows = rows ++ [(g, c)]; - i = i + 1; - } - dispatch(DistributionLoaded(rows)) - } - Err(msg) => dispatch(LoadFailed(msg)), +fn refresh = async (dispatch: action => unit) => { + dispatch(LoadStarted) + switch await VcldbClient.gradeDistribution() { + | Ok(result) => + fn rows = result.rows->Belt.Array.map(row => { + fn g = row["semantic"]->Js.Dict.get("grade")->Belt.Option.flatMap(Js.Json.decodeString) + fn c = row["semantic"]->Js.Dict.get("count")->Belt.Option.flatMap(Js.Json.decodeNumber) + (g->Belt.Option.getWithDefault("?"), c->Belt.Option.mapWithDefault(0, f => f->Belt.Float.toInt)) + }) + dispatch(DistributionLoaded(rows)) + | Error(msg) => dispatch(LoadFailed(msg)) } } -pub fn make() -> ReactNode { - let (state, dispatch) = react_use_reducer(reduce, initial); +@react.component +fn make = () => { + fn (state, dispatch) = React.useReducer(reduce, initial) - react_use_effect0(fn() { - refresh(dispatch); - let id = set_interval(fn() { refresh(dispatch); }, 60000); - Some(fn() { clear_interval(id); }) - }); + React.useEffect0(() => { + refresh(dispatch)->ignore + fn id = Js.Global.setInterval(() => refresh(dispatch)->ignore, 60000) + Some(() => Js.Global.clearInterval(id)) + }) - let error_node = match state.error { - Some(msg) => h("div", json_object([("className", json_string("error"))]), [react_string(msg)]), - None => react_null(), - }; - - let body = if state.loading { - h("div", json_object([("className", json_string("spinner"))]), [react_string("Loading...")]) - } else { - let rows = []; - let i = 0; - while i < len(state.distribution) { - let (grade, count) = state.distribution[i]; - rows = rows ++ [h("tr", json_object([("key", json_string(grade))]), [ - h("td", json_object([]), [react_string(grade)]), - h("td", json_object([]), [react_int(count)]), - ])]; - i = i + 1; - } - h("table", json_object([("className", json_string("grade-matrix"))]), [ - h("thead", json_object([]), [ - h("tr", json_object([]), [ - h("th", json_object([]), [react_string("Grade")]), - h("th", json_object([]), [react_string("Count")]), - ]), - ]), - h("tbody", json_object([]), [react_array(rows)]), - ]) - }; - - h("div", json_object([("className", json_string("panel panel-crg-dashboard"))]), [ - h("h2", json_object([]), [react_string("CRG Dashboard")]), - error_node, - body, - ]) +
+

{React.string("CRG Dashboard")}

+ {switch state.error { + | Some(msg) =>
{React.string(msg)}
+ | None => React.null + }} + {state.loading + ?
{React.string("Loading...")}
+ : + + + + + + + + {state.distribution + ->Belt.Array.map(((grade, count)) => + + + + + ) + ->React.array} + +
{React.string("Grade")} {React.string("Count")}
{React.string(grade)} {React.int(count)}
} +
} + diff --git a/panll-panels/src/ProofHub.affine b/panll-panels/src/ProofHub.affine index 1d40da672..9a19e59de 100644 --- a/panll-panels/src/ProofHub.affine +++ b/panll-panels/src/ProofHub.affine @@ -1,139 +1,118 @@ // SPDX-License-Identifier: MPL-2.0 -// Proof Verification Hub panel. AffineScript port of ProofHub.res. +// Ported via Harvard Engine (Semantic pass) module ProofHub; -use VcldbClient; +// SPDX-License-Identifier: PMPL-1.0-or-later +// Proof Verification Hub panel — proof table with trust level & staleness. -extern type ReactNode; -extern fn react_use_reducer(reduce: fn(State, Action) -> State, initial: State) -> (State, fn(Action) -> Unit) = "react" "useReducer"; -extern fn react_use_effect0(body: fn() -> Option Unit>) -> Unit = "react" "useEffect0"; -extern fn react_string(s: String) -> ReactNode = "react" "string"; -extern fn react_array(xs: [ReactNode]) -> ReactNode = "react" "array"; -extern fn react_null() -> ReactNode = "react" "null"; -extern fn h(tag: String, props: Json, children: [ReactNode]) -> ReactNode = "react" "h"; -extern fn set_interval(cb: fn() -> Unit, ms: Int) -> Int = "global" "setInterval"; -extern fn clear_interval(id: Int) -> Unit = "global" "clearInterval"; -extern fn json_decode_string(j: Json) -> Option = "json" "decodeString"; - -pub type Proof = { - file: String, - prover: String, - theorem_name: String, - trust_level: String, - verified_at: String, +struct proof { { + file: string, + prover: string, + theoremName: string, + trustLevel: string, + verifiedAt: string, } -pub type State = { - loading: Bool, - proofs: [Proof], - error: Option, +struct state { { + loading: bool, + proofs: array, + error: option, } -pub type Action = +struct action { | LoadStarted - | ProofsLoaded([Proof]) - | LoadFailed(String) - -pub let initial = State { loading: false, proofs: [], error: None }; + | ProofsLoaded(array) + | LoadFailed(string) -pub fn reduce(state: State, action: Action) -> State { - match action { - LoadStarted => State { ...state, loading: true, error: None }, - ProofsLoaded(rows) => State { ...state, loading: false, proofs: rows }, - LoadFailed(msg) => State { ...state, loading: false, error: Some(msg) }, - } -} +fn initial: state = {loading: false, proofs: [], error: None} -fn pick(semantic: Dict, key: String, fallback: String) -> String { - match dict_get(semantic, key) { - Some(j) => match json_decode_string(j) { Some(s) => s, None => fallback }, - None => fallback, +fn reduce = (state: state, action: action): state => + switch action { + | LoadStarted => {...state, loading: true, error: None} + | ProofsLoaded(rows) => {...state, loading: false, proofs: rows} + | LoadFailed(msg) => {...state, loading: false, error: Some(msg)} } -} -pub fn refresh(dispatch: fn(Action) -> Unit) -> Effect[Async] Unit { - dispatch(LoadStarted); - match await VcldbClient.current_proofs() { - Ok(result) => { - let proofs = []; - let i = 0; - while i < len(result.rows) { - let row = result.rows[i]; - proofs = proofs ++ [Proof { - file: pick(row.semantic, "file", "?"), - prover: pick(row.semantic, "prover", "?"), - theorem_name: pick(row.semantic, "theorem_name", "?"), - trust_level: pick(row.semantic, "trust_level", "unreviewed"), - verified_at: pick(row.temporal, "verified_at", "never"), - }]; - i = i + 1; +fn pick = (semantic: Js.Dict.t, key: string, fallback: string): string => + semantic + ->Js.Dict.get(key) + ->Belt.Option.flatMap(Js.Json.decodeString) + ->Belt.Option.getWithDefault(fallback) + +fn refresh = async (dispatch: action => unit) => { + dispatch(LoadStarted) + switch await VcldbClient.currentProofs() { + | Ok(result) => + fn proofs = result.rows->Belt.Array.map(row => { + fn s = row["semantic"] + fn t = row["temporal"] + { + file: pick(s, "file", "?"), + prover: pick(s, "prover", "?"), + theoremName: pick(s, "theorem_name", "?"), + trustLevel: pick(s, "trust_level", "unreviewed"), + verifiedAt: pick(t, "verified_at", "never"), } - dispatch(ProofsLoaded(proofs)) - } - Err(msg) => dispatch(LoadFailed(msg)), + }) + dispatch(ProofsLoaded(proofs)) + | Error(msg) => dispatch(LoadFailed(msg)) } } -pub fn trust_badge(level: String) -> String { - match level { - "proven" => "✓ proven", - "tested" => "● tested", - "reviewed" => "◉ reviewed", - "postulate" => "▲ postulate", - "axiom" => "★ axiom", - "admitted" => "✗ admitted", - _ => "? " ++ level, +fn trustBadge = (level: string): string => + switch level { + | "proven" => "✓ proven" + | "tested" => "● tested" + | "reviewed" => "◉ reviewed" + | "postulate" => "▲ postulate" + | "axiom" => "★ axiom" + | "admitted" => "✗ admitted" + | _ => "? " ++ level } -} -pub fn make() -> ReactNode { - let (state, dispatch) = react_use_reducer(reduce, initial); - - react_use_effect0(fn() { - refresh(dispatch); - let id = set_interval(fn() { refresh(dispatch); }, 120000); - Some(fn() { clear_interval(id); }) - }); - - let error_node = match state.error { - Some(msg) => h("div", json_object([("className", json_string("error"))]), [react_string(msg)]), - None => react_null(), - }; - - let body = if state.loading { - h("div", json_object([("className", json_string("spinner"))]), [react_string("Loading...")]) - } else { - let rows = []; - let i = 0; - while i < len(state.proofs) { - let p = state.proofs[i]; - rows = rows ++ [h("tr", json_object([("key", json_string(show(i)))]), [ - h("td", json_object([]), [react_string(p.file)]), - h("td", json_object([]), [react_string(p.prover)]), - h("td", json_object([]), [react_string(p.theorem_name)]), - h("td", json_object([]), [react_string(trust_badge(p.trust_level))]), - h("td", json_object([]), [react_string(p.verified_at)]), - ])]; - i = i + 1; - } - h("table", json_object([("className", json_string("proof-table"))]), [ - h("thead", json_object([]), [ - h("tr", json_object([]), [ - h("th", json_object([]), [react_string("File")]), - h("th", json_object([]), [react_string("Prover")]), - h("th", json_object([]), [react_string("Theorem")]), - h("th", json_object([]), [react_string("Trust")]), - h("th", json_object([]), [react_string("Verified")]), - ]), - ]), - h("tbody", json_object([]), [react_array(rows)]), - ]) - }; - - h("div", json_object([("className", json_string("panel panel-proof-hub"))]), [ - h("h2", json_object([]), [react_string("Proof Verification Hub")]), - error_node, - body, - ]) +@react.component +fn make = () => { + fn (state, dispatch) = React.useReducer(reduce, initial) + + React.useEffect0(() => { + refresh(dispatch)->ignore + fn id = Js.Global.setInterval(() => refresh(dispatch)->ignore, 120000) + Some(() => Js.Global.clearInterval(id)) + }) + +
+

{React.string("Proof Verification Hub")}

+ {switch state.error { + | Some(msg) =>
{React.string(msg)}
+ | None => React.null + }} + {state.loading + ?
{React.string("Loading...")}
+ : + + + + + + + + + + + {state.proofs + ->Belt.Array.mapWithIndex((i, p) => + + + + + + + + ) + ->React.array} + +
{React.string("File")} {React.string("Prover")} {React.string("Theorem")} {React.string("Trust")} {React.string("Verified")}
{React.string(p.file)} {React.string(p.prover)} {React.string(p.theoremName)} {React.string(trustBadge(p.trustLevel))} {React.string(p.verifiedAt)}
} +
} + diff --git a/panll-panels/src/VcldbClient.affine b/panll-panels/src/VcldbClient.affine index a6d3be5e2..7d97bc1a8 100644 --- a/panll-panels/src/VcldbClient.affine +++ b/panll-panels/src/VcldbClient.affine @@ -1,55 +1,65 @@ // SPDX-License-Identifier: MPL-2.0 -// VeriSimDB query client. AffineScript port of VcldbClient.res. +// Ported via Harvard Engine (Semantic pass) module VcldbClient; -extern fn fetch_post(url: String, init: Json) -> Promise = "global" "fetch"; -extern fn json_stringify_any(v: a) -> Option = "JSON" "stringifyAny"; -extern fn json_to_query_result(j: Json) -> QueryResult = "json" "asQueryResult"; +// SPDX-License-Identifier: PMPL-1.0-or-later +// VeriSimDB query client — executes VCL queries, returns octad rows. +// +// Thin wrapper over fetch(). Consumers (panels) call one of the +// structd helpers below rather than constructing raw VCL strings. -pub type OctadRow = { - id: String, - entity: String, - semantic: Dict, - temporal: Dict, +struct octadRow { { + "id": string, + "entity": string, + "semantic": Js.Dict.t, + "temporal": Js.Dict.t, } -pub type QueryResult = { - rows: [OctadRow], - query_ms: Int, +struct queryResult { { + rows: array, + queryMs: int, } -pub let base_url = "http://localhost:8097"; - -pub fn run_vcl(vcl: String) -> Effect[Async] Result { - let body = match json_stringify_any(json_object([("vcl", json_string(vcl))])) { - Some(s) => s, None => "{}", - }; - let init = json_object([ - ("method", json_string("POST")), - ("headers", json_object([("Content-Type", json_string("application/json"))])), - ("body", json_string(body)), - ]); +@val external fetch: (string, 'a) => promise<'b> = "fetch" + +fn baseUrl = "http://localhost:8097" + +fn runVcl = async (vcl: string): result => { + fn body = Js.Json.stringifyAny({"vcl": vcl})->Belt.Option.getWithDefault("{}") + fn init = { + "method": "POST", + "headers": Js.Dict.fromArray([("Content-Type", "application/json")]), + "body": body, + } try { - let resp = await fetch_post(base_url ++ "/vcl/query", init); - Ok(json_to_query_result(resp)) - } catch _e { - Err("VeriSimDB query failed — is it running on port 8097?") + fn resp = await fetch(baseUrl ++ "/vcl/query", init) + fn json: queryResult = %raw("resp.json()") + Ok(json) + } catch { + | _ => Error("VeriSimDB query failed — is it running on port 8097?") } } -pub fn grade_distribution() -> Effect[Async] Result { - run_vcl("octads(entity=\"crg-grade\") |> latest_per(\"component\") |> group_by(\"grade\") |> count()") -} +// ───────────────────────────────────────────────────────────── +// Typed queries — one per panel use-case +// ───────────────────────────────────────────────────────────── -pub fn open_findings() -> Effect[Async] Result { - run_vcl("octads(entity=\"compliance-scan\") |> where(\"temporal.resolved_at == null\") |> group_by(\"repo, severity\") |> count()") -} +fn gradeDistribution = () => + runVcl(`octads(entity="crg-grade") |> latest_per("component") |> group_by("grade") |> count()`) -pub fn current_proofs() -> Effect[Async] Result { - run_vcl("octads(entity=\"proof-status\") |> latest_per(\"file, theorem_name\") |> project(\"file, prover, theorem_name, trust_level, verified_at\")") -} +fn openFindings = () => + runVcl( + `octads(entity="compliance-scan") |> where("temporal.resolved_at == null") |> group_by("repo, severity") |> count()`, + ) + +fn currentProofs = () => + runVcl( + `octads(entity="proof-status") |> latest_per("file, theorem_name") |> project("file, prover, theorem_name, trust_level, verified_at")`, + ) + +fn staleProofs = (days: int) => + runVcl( + `octads(entity="proof-status") |> where("temporal.proof_age_days > ${days->Belt.Int.toString}") |> order_by("proof_age_days desc")`, + ) -pub fn stale_proofs(days: Int) -> Effect[Async] Result { - run_vcl("octads(entity=\"proof-status\") |> where(\"temporal.proof_age_days > " ++ show(days) ++ "\") |> order_by(\"proof_age_days desc\")") -} diff --git a/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/BlueAgent.affine b/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/BlueAgent.affine index 0bae5e6d2..d420582d2 100644 --- a/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/BlueAgent.affine +++ b/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/BlueAgent.affine @@ -1,20 +1,21 @@ // SPDX-License-Identifier: MPL-2.0 -// BlueAgent — The Auditor. AffineScript port of BlueAgent.res. +// Ported via Harvard Engine (Semantic pass) module BlueAgent; -use Types; +// BlueAgent.res - The Auditor +// Teaches: Verification, auditing, tracing, debugging, proof systems -extern fn random_int(lo: Int, hi: Int) -> Int = "Math" "randomInt"; +open Types -pub let names = Types.AgentNames { +fn names: agentNames = { cuttle: "Detective Blue", squidlet: "Tracker Blue", duet: "Verification Agent Blue", octopus: "Verification Oracle Blue", -}; +} -pub let personality = Types.Personality { +fn personality: personality = { voice: "Curious and analytical, always asking questions and looking for clues", catchphrase: "The evidence never lies!", encouragement: [ @@ -35,9 +36,9 @@ pub let personality = Types.Personality { "Irrefutable proof! Amazing!", "The mystery is completely solved!", ], -}; +} -pub let teaches = [ +fn teaches = [ "Logical deduction", "Evidence gathering", "Proof construction", @@ -46,58 +47,66 @@ pub let teaches = [ "Hoare logic", "Formal verification", "Theorem proving", -]; +] -pub fn get_name(stage: Types.Stage) -> String { - match stage { - Cuttle => names.cuttle, - Squidlet => names.squidlet, - Duet => names.duet, - Octopus => names.octopus, +// Get the agent's name for a given stage +fn getName = (stage: stage): string => { + switch stage { + | Cuttle => names.cuttle + | Squidfn => names.squidlet + | Duet => names.duet + | Octopus => names.octopus } } -pub fn get_hidden_concept(stage: Types.Stage) -> String { - match stage { - Cuttle => "Logical deduction and proof", - Squidlet => "Logging and execution tracing", - Duet => "Formal verification and Hoare logic", - Octopus => "Theorem proving and certified systems", +// Get what the agent is "secretly" teaching at each stage +fn getHiddenConcept = (stage: stage): string => { + switch stage { + | Cuttle => "Logical deduction and proof" + | Squidfn => "Logging and execution tracing" + | Duet => "Formal verification and Hoare logic" + | Octopus => "Theorem proving and certified systems" } } -pub fn encourage() -> String { - let idx = random_int(0, len(personality.encouragement)); - match array_get(personality.encouragement, idx) { Some(s) => s, None => personality.catchphrase } +// Get a random encouragement message +fn encourage = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.encouragement)) + personality.encouragement[idx]->Option.getOr(personality.catchphrase) } -pub fn correct() -> String { - let idx = random_int(0, len(personality.corrections)); - match array_get(personality.corrections, idx) { Some(s) => s, None => "Let's try again!" } +// Get a random correction message +fn correct = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.corrections)) + personality.corrections[idx]->Option.getOr("Let's try again!") } -pub fn celebrate() -> String { - let idx = random_int(0, len(personality.celebrations)); - match array_get(personality.celebrations, idx) { Some(s) => s, None => personality.catchphrase } +// Get a random celebration message +fn celebrate = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.celebrations)) + personality.celebrations[idx]->Option.getOr(personality.catchphrase) } -pub let agent = Types.Agent { - color: Types.Blue, - names: names, - compiler_role: "Auditor - Verifies correctness and provides formal proofs", - teaches: teaches, - personality: personality, - lessons: [], -}; +// Create the complete agent definition +fn agent: agent = { + color: Blue, + names, + compilerRole: "Auditor - Verifies correctness and provides formal proofs", + teaches, + personality, + lessons: [], // Populated from curriculum files +} -pub fn reveal_text(from_stage: Types.Stage, to_stage: Types.Stage) -> Option { - match (from_stage, to_stage) { - (Cuttle, Squidlet) => - Some("All those mysteries you solved? Blue was teaching you about VERIFICATION! Every clue you found was like evidence that proves code is correct."), - (Squidlet, Duet) => - Some("Blue has been teaching you FORMAL VERIFICATION! When you proved who did it, you were learning how mathematicians prove that code can never fail."), - (Duet, Octopus) => - Some("You understand verification systems now! You know how Blue can mathematically PROVE that code is correct, not just test it."), - _ => None, +// Reveal text shown when transitioning stages +fn revealText = (fromStage: stage, toStage: stage): option => { + switch (fromStage, toStage) { + | (Cuttle, Squidlet) => + Some("All those mysteries you solved? Blue was teaching you about VERIFICATION! Every clue you found was like evidence that proves code is correct.") + | (Squidlet, Duet) => + Some("Blue has been teaching you FORMAL VERIFICATION! When you proved who did it, you were learning how mathematicians prove that code can never fail.") + | (Duet, Octopus) => + Some("You understand verification systems now! You know how Blue can mathematically PROVE that code is correct, not just test it.") + | _ => None } } + diff --git a/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/GreenAgent.affine b/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/GreenAgent.affine index 4c8736a3c..f2cf5eb74 100644 --- a/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/GreenAgent.affine +++ b/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/GreenAgent.affine @@ -1,20 +1,21 @@ // SPDX-License-Identifier: MPL-2.0 -// GreenAgent — The AST Architect. AffineScript port of GreenAgent.res. +// Ported via Harvard Engine (Semantic pass) module GreenAgent; -use Types; +// GreenAgent.res - The AST Architect +// Teaches: Abstract syntax trees, code representation, manipulation -extern fn random_int(lo: Int, hi: Int) -> Int = "Math" "randomInt"; +open Types -pub let names = Types.AgentNames { +fn names: agentNames = { cuttle: "Builder Green", squidlet: "Maker Green", duet: "Structure Agent Green", octopus: "Code Architecture Specialist Green", -}; +} -pub let personality = Types.Personality { +fn personality: personality = { voice: "Creative and constructive, always excited about building new things", catchphrase: "Let's build something amazing!", encouragement: [ @@ -35,9 +36,9 @@ pub let personality = Types.Personality { "You built something incredible!", "That structure will stand forever!", ], -}; +} -pub let teaches = [ +fn teaches = [ "Composition and construction", "Hierarchical thinking", "Tree structures", @@ -46,58 +47,66 @@ pub let teaches = [ "Intermediate representations", "Code generation", "Optimization passes", -]; +] -pub fn get_name(stage: Types.Stage) -> String { - match stage { - Cuttle => names.cuttle, - Squidlet => names.squidlet, - Duet => names.duet, - Octopus => names.octopus, +// Get the agent's name for a given stage +fn getName = (stage: stage): string => { + switch stage { + | Cuttle => names.cuttle + | Squidfn => names.squidlet + | Duet => names.duet + | Octopus => names.octopus } } -pub fn get_hidden_concept(stage: Types.Stage) -> String { - match stage { - Cuttle => "Composition and hierarchy", - Squidlet => "Tree structures and data representation", - Duet => "AST construction and manipulation", - Octopus => "Compiler IR and code generation", +// Get what the agent is "secretly" teaching at each stage +fn getHiddenConcept = (stage: stage): string => { + switch stage { + | Cuttle => "Composition and hierarchy" + | Squidfn => "Tree structures and data representation" + | Duet => "AST construction and manipulation" + | Octopus => "Compiler IR and code generation" } } -pub fn encourage() -> String { - let idx = random_int(0, len(personality.encouragement)); - match array_get(personality.encouragement, idx) { Some(s) => s, None => personality.catchphrase } +// Get a random encouragement message +fn encourage = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.encouragement)) + personality.encouragement[idx]->Option.getOr(personality.catchphrase) } -pub fn correct() -> String { - let idx = random_int(0, len(personality.corrections)); - match array_get(personality.corrections, idx) { Some(s) => s, None => "Let's try again!" } +// Get a random correction message +fn correct = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.corrections)) + personality.corrections[idx]->Option.getOr("Let's try again!") } -pub fn celebrate() -> String { - let idx = random_int(0, len(personality.celebrations)); - match array_get(personality.celebrations, idx) { Some(s) => s, None => personality.catchphrase } +// Get a random celebration message +fn celebrate = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.celebrations)) + personality.celebrations[idx]->Option.getOr(personality.catchphrase) } -pub let agent = Types.Agent { - color: Types.Green, - names: names, - compiler_role: "AST Architect - Builds and transforms code representations", - teaches: teaches, - personality: personality, - lessons: [], -}; +// Create the complete agent definition +fn agent: agent = { + color: Green, + names, + compilerRole: "AST Architect - Builds and transforms code representations", + teaches, + personality, + lessons: [], // Populated from curriculum files +} -pub fn reveal_text(from_stage: Types.Stage, to_stage: Types.Stage) -> Option { - match (from_stage, to_stage) { - (Cuttle, Squidlet) => - Some("All those building blocks? Green was teaching you about STRUCTURE! Every tower you built was like a tree of code, with branches and leaves."), - (Squidlet, Duet) => - Some("Green has been teaching you about ABSTRACT SYNTAX TREES! When you assembled pieces into complex structures, you were learning how compilers represent code internally."), - (Duet, Octopus) => - Some("You understand code architecture now! You know how Green transforms human-readable code into tree structures that computers can execute."), - _ => None, +// Reveal text shown when transitioning stages +fn revealText = (fromStage: stage, toStage: stage): option => { + switch (fromStage, toStage) { + | (Cuttle, Squidlet) => + Some("All those building blocks? Green was teaching you about STRUCTURE! Every tower you built was like a tree of code, with branches and leaves.") + | (Squidlet, Duet) => + Some("Green has been teaching you about ABSTRACT SYNTAX TREES! When you assembled pieces into complex structures, you were learning how compilers represent code internally.") + | (Duet, Octopus) => + Some("You understand code architecture now! You know how Green transforms human-readable code into tree structures that computers can execute.") + | _ => None } } + diff --git a/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/IndigoAgent.affine b/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/IndigoAgent.affine index 55e9fa84c..ea509b376 100644 --- a/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/IndigoAgent.affine +++ b/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/IndigoAgent.affine @@ -1,21 +1,21 @@ // SPDX-License-Identifier: MPL-2.0 -// IndigoAgent — The Compile-Time Metaprogrammer. -// AffineScript port of IndigoAgent.res. +// Ported via Harvard Engine (Semantic pass) module IndigoAgent; -use Types; +// IndigoAgent.res - The Compile-Time Metaprogrammer +// Teaches: Metaprogramming, compile-time evaluation, macros, reflection -extern fn random_int(lo: Int, hi: Int) -> Int = "Math" "randomInt"; +open Types -pub let names = Types.AgentNames { +fn names: agentNames = { cuttle: "Magic Indigo", squidlet: "Spell Indigo", duet: "Metaprogramming Wizard Indigo", octopus: "Compile-Time Execution Master Indigo", -}; +} -pub let personality = Types.Personality { +fn personality: personality = { voice: "Mysterious and whimsical, speaking in riddles that reveal deep truths", catchphrase: "The real magic happens before the show begins!", encouragement: [ @@ -36,9 +36,9 @@ pub let personality = Types.Personality { "A spell for the ages!", "True wizardry! Extraordinary!", ], -}; +} -pub let teaches = [ +fn teaches = [ "Transformation and pattern rules", "Abstraction and shortcuts", "Template systems", @@ -47,58 +47,66 @@ pub let teaches = [ "Staged computation", "Partial evaluation", "Code generation", -]; +] -pub fn get_name(stage: Types.Stage) -> String { - match stage { - Cuttle => names.cuttle, - Squidlet => names.squidlet, - Duet => names.duet, - Octopus => names.octopus, +// Get the agent's name for a given stage +fn getName = (stage: stage): string => { + switch stage { + | Cuttle => names.cuttle + | Squidfn => names.squidlet + | Duet => names.duet + | Octopus => names.octopus } } -pub fn get_hidden_concept(stage: Types.Stage) -> String { - match stage { - Cuttle => "Transformation and rule-based patterns", - Squidlet => "Macros and templating", - Duet => "Compile-time evaluation and staging", - Octopus => "Partial evaluation and supercompilation", +// Get what the agent is "secretly" teaching at each stage +fn getHiddenConcept = (stage: stage): string => { + switch stage { + | Cuttle => "Transformation and rule-based patterns" + | Squidfn => "Macros and templating" + | Duet => "Compile-time evaluation and staging" + | Octopus => "Partial evaluation and supercompilation" } } -pub fn encourage() -> String { - let idx = random_int(0, len(personality.encouragement)); - match array_get(personality.encouragement, idx) { Some(s) => s, None => personality.catchphrase } +// Get a random encouragement message +fn encourage = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.encouragement)) + personality.encouragement[idx]->Option.getOr(personality.catchphrase) } -pub fn correct() -> String { - let idx = random_int(0, len(personality.corrections)); - match array_get(personality.corrections, idx) { Some(s) => s, None => "Let's try again!" } +// Get a random correction message +fn correct = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.corrections)) + personality.corrections[idx]->Option.getOr("Let's try again!") } -pub fn celebrate() -> String { - let idx = random_int(0, len(personality.celebrations)); - match array_get(personality.celebrations, idx) { Some(s) => s, None => personality.catchphrase } +// Get a random celebration message +fn celebrate = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.celebrations)) + personality.celebrations[idx]->Option.getOr(personality.catchphrase) } -pub let agent = Types.Agent { - color: Types.Indigo, - names: names, - compiler_role: "Metaprogrammer - Executes code at compile time to generate optimized code", - teaches: teaches, - personality: personality, - lessons: [], -}; +// Create the complete agent definition +fn agent: agent = { + color: Indigo, + names, + compilerRole: "Metaprogrammer - Executes code at compile time to generate optimized code", + teaches, + personality, + lessons: [], // Populated from curriculum files +} -pub fn reveal_text(from_stage: Types.Stage, to_stage: Types.Stage) -> Option { - match (from_stage, to_stage) { - (Cuttle, Squidlet) => - Some("All that magic? Indigo was teaching you about TRANSFORMATION! Every spell you cast was like a program that writes other programs."), - (Squidlet, Duet) => - Some("Indigo has been teaching you METAPROGRAMMING! When you cast spells that created new spells, you were learning how code can generate code."), - (Duet, Octopus) => - Some("You understand compile-time execution now! You know how Indigo runs code BEFORE the program runs, creating specialized, optimized programs."), - _ => None, +// Reveal text shown when transitioning stages +fn revealText = (fromStage: stage, toStage: stage): option => { + switch (fromStage, toStage) { + | (Cuttle, Squidlet) => + Some("All that magic? Indigo was teaching you about TRANSFORMATION! Every spell you cast was like a program that writes other programs.") + | (Squidlet, Duet) => + Some("Indigo has been teaching you METAPROGRAMMING! When you cast spells that created new spells, you were learning how code can generate code.") + | (Duet, Octopus) => + Some("You understand compile-time execution now! You know how Indigo runs code BEFORE the program runs, creating specialized, optimized programs.") + | _ => None } } + diff --git a/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/OrangeAgent.affine b/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/OrangeAgent.affine index 11984d585..e878291f2 100644 --- a/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/OrangeAgent.affine +++ b/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/OrangeAgent.affine @@ -1,20 +1,21 @@ // SPDX-License-Identifier: MPL-2.0 -// OrangeAgent — The Concurrency Engine. AffineScript port of OrangeAgent.res. +// Ported via Harvard Engine (Semantic pass) module OrangeAgent; -use Types; +// OrangeAgent.res - The Concurrency Engine +// Teaches: Async/await, scheduling, event loops, concurrency -extern fn random_int(lo: Int, hi: Int) -> Int = "Math" "randomInt"; +open Types -pub let names = Types.AgentNames { +fn names: agentNames = { cuttle: "Juggler Orange", squidlet: "Event Orange", duet: "Concurrency Agent Orange", octopus: "Concurrency Orchestrator Orange", -}; +} -pub let personality = Types.Personality { +fn personality: personality = { voice: "Calm and rhythmic, always counting beats and keeping things in sync", catchphrase: "Keep all the balls in the air!", encouragement: [ @@ -35,9 +36,9 @@ pub let personality = Types.Personality { "Master juggler achievement unlocked!", "Not a single drop! Incredible!", ], -}; +} -pub let teaches = [ +fn teaches = [ "Coordination and timing", "Sequencing multiple tasks", "Event-driven thinking", @@ -46,58 +47,66 @@ pub let teaches = [ "Promise chains", "Race condition awareness", "Scheduler design", -]; +] -pub fn get_name(stage: Types.Stage) -> String { - match stage { - Cuttle => names.cuttle, - Squidlet => names.squidlet, - Duet => names.duet, - Octopus => names.octopus, +// Get the agent's name for a given stage +fn getName = (stage: stage): string => { + switch stage { + | Cuttle => names.cuttle + | Squidfn => names.squidlet + | Duet => names.duet + | Octopus => names.octopus } } -pub fn get_hidden_concept(stage: Types.Stage) -> String { - match stage { - Cuttle => "Timing and coordination basics", - Squidlet => "Event systems and queues", - Duet => "Async/await and promises", - Octopus => "Concurrent system architecture", +// Get what the agent is "secretly" teaching at each stage +fn getHiddenConcept = (stage: stage): string => { + switch stage { + | Cuttle => "Timing and coordination basics" + | Squidfn => "Event systems and queues" + | Duet => "Async/await and promises" + | Octopus => "Concurrent system architecture" } } -pub fn encourage() -> String { - let idx = random_int(0, len(personality.encouragement)); - match array_get(personality.encouragement, idx) { Some(s) => s, None => personality.catchphrase } +// Get a random encouragement message +fn encourage = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.encouragement)) + personality.encouragement[idx]->Option.getOr(personality.catchphrase) } -pub fn correct() -> String { - let idx = random_int(0, len(personality.corrections)); - match array_get(personality.corrections, idx) { Some(s) => s, None => "Let's try again!" } +// Get a random correction message +fn correct = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.corrections)) + personality.corrections[idx]->Option.getOr("Let's try again!") } -pub fn celebrate() -> String { - let idx = random_int(0, len(personality.celebrations)); - match array_get(personality.celebrations, idx) { Some(s) => s, None => personality.catchphrase } +// Get a random celebration message +fn celebrate = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.celebrations)) + personality.celebrations[idx]->Option.getOr(personality.catchphrase) } -pub let agent = Types.Agent { - color: Types.Orange, - names: names, - compiler_role: "Concurrency Engine - Manages parallel execution and scheduling", - teaches: teaches, - personality: personality, - lessons: [], -}; +// Create the complete agent definition +fn agent: agent = { + color: Orange, + names, + compilerRole: "Concurrency Engine - Manages parallel execution and scheduling", + teaches, + personality, + lessons: [], // Populated from curriculum files +} -pub fn reveal_text(from_stage: Types.Stage, to_stage: Types.Stage) -> Option { - match (from_stage, to_stage) { - (Cuttle, Squidlet) => - Some("All that juggling? Orange wasn't just teaching you to catch balls - Orange was teaching you to handle EVENTS. Each ball was like a task waiting for its turn!"), - (Squidlet, Duet) => - Some("Orange has been teaching you CONCURRENCY! When you juggled multiple balls, you were learning how computers handle many tasks at once without dropping any."), - (Duet, Octopus) => - Some("You understand concurrent systems now! You know how Orange schedules which task runs when, preventing race conditions and keeping everything in harmony."), - _ => None, +// Reveal text shown when transitioning stages +fn revealText = (fromStage: stage, toStage: stage): option => { + switch (fromStage, toStage) { + | (Cuttle, Squidlet) => + Some("All that juggling? Orange wasn't just teaching you to catch balls - Orange was teaching you to handle EVENTS. Each ball was like a task waiting for its turn!") + | (Squidlet, Duet) => + Some("Orange has been teaching you CONCURRENCY! When you juggled multiple balls, you were learning how computers handle many tasks at once without dropping any.") + | (Duet, Octopus) => + Some("You understand concurrent systems now! You know how Orange schedules which task runs when, preventing race conditions and keeping everything in harmony.") + | _ => None } } + diff --git a/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/RedAgent.affine b/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/RedAgent.affine index 740c14cdb..65575986a 100644 --- a/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/RedAgent.affine +++ b/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/RedAgent.affine @@ -1,20 +1,21 @@ // SPDX-License-Identifier: MPL-2.0 -// RedAgent — The Parser. AffineScript port of RedAgent.res. +// Ported via Harvard Engine (Semantic pass) module RedAgent; -use Types; +// RedAgent.res - The Parser +// Teaches: Lexical analysis → Parsing → Syntax trees -extern fn random_int(lo: Int, hi: Int) -> Int = "Math" "randomInt"; +open Types -pub let names = Types.AgentNames { +fn names: agentNames = { cuttle: "Speedy Red", squidlet: "Fast Finder Red", duet: "Performance Agent Red", octopus: "Performance Agent Red", -}; +} -pub let personality = Types.Personality { +fn personality: personality = { voice: "Energetic and fast-talking, always excited about speed and efficiency", catchphrase: "Let's zoom through this!", encouragement: [ @@ -35,9 +36,9 @@ pub let personality = Types.Personality { "That was lightning fast!", "You've mastered this track!", ], -}; +} -pub let teaches = [ +fn teaches = [ "Pattern recognition", "Algorithmic thinking", "Efficiency and optimization", @@ -46,58 +47,66 @@ pub let teaches = [ "Grammar and syntax rules", "Tokenization", "Abstract syntax tree construction", -]; +] -pub fn get_name(stage: Types.Stage) -> String { - match stage { - Cuttle => names.cuttle, - Squidlet => names.squidlet, - Duet => names.duet, - Octopus => names.octopus, +// Get the agent's name for a given stage +fn getName = (stage: stage): string => { + switch stage { + | Cuttle => names.cuttle + | Squidfn => names.squidlet + | Duet => names.duet + | Octopus => names.octopus } } -pub fn get_hidden_concept(stage: Types.Stage) -> String { - match stage { - Cuttle => "Pattern recognition and rule-following", - Squidlet => "Algorithmic complexity and optimization", - Duet => "Lexical analysis and parsing", - Octopus => "Complete parser implementation", +// Get what the agent is "secretly" teaching at each stage +fn getHiddenConcept = (stage: stage): string => { + switch stage { + | Cuttle => "Pattern recognition and rule-following" + | Squidfn => "Algorithmic complexity and optimization" + | Duet => "Lexical analysis and parsing" + | Octopus => "Complete parser implementation" } } -pub fn encourage() -> String { - let idx = random_int(0, len(personality.encouragement)); - match array_get(personality.encouragement, idx) { Some(s) => s, None => personality.catchphrase } +// Get a random encouragement message +fn encourage = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.encouragement)) + personality.encouragement[idx]->Option.getOr(personality.catchphrase) } -pub fn correct() -> String { - let idx = random_int(0, len(personality.corrections)); - match array_get(personality.corrections, idx) { Some(s) => s, None => "Let's try again!" } +// Get a random correction message +fn correct = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.corrections)) + personality.corrections[idx]->Option.getOr("Let's try again!") } -pub fn celebrate() -> String { - let idx = random_int(0, len(personality.celebrations)); - match array_get(personality.celebrations, idx) { Some(s) => s, None => personality.catchphrase } +// Get a random celebration message +fn celebrate = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.celebrations)) + personality.celebrations[idx]->Option.getOr(personality.catchphrase) } -pub let agent = Types.Agent { - color: Types.Red, - names: names, - compiler_role: "Parser - Transforms source code into structured syntax trees", - teaches: teaches, - personality: personality, - lessons: [], -}; +// Create the complete agent definition +fn agent: agent = { + color: Red, + names, + compilerRole: "Parser - Transforms source code into structured syntax trees", + teaches, + personality, + lessons: [], // Populated from curriculum files +} -pub fn reveal_text(from_stage: Types.Stage, to_stage: Types.Stage) -> Option { - match (from_stage, to_stage) { - (Cuttle, Squidlet) => - Some("Remember all those racing games? Red wasn't just teaching you to go fast - Red was teaching you to find PATTERNS. Every race track was like a sentence, and you learned to read them!"), - (Squidlet, Duet) => - Some("Red has been teaching you PARSING all along! When you found the fastest path through obstacles, you were learning how compilers break down code into pieces they can understand."), - (Duet, Octopus) => - Some("You've mastered what Red teaches: lexical analysis and parsing. You can now build the first stages of a real compiler!"), - _ => None, +// Reveal text shown when transitioning stages +fn revealText = (fromStage: stage, toStage: stage): option => { + switch (fromStage, toStage) { + | (Cuttle, Squidlet) => + Some("Remember all those racing games? Red wasn't just teaching you to go fast - Red was teaching you to find PATTERNS. Every race track was like a sentence, and you learned to read them!") + | (Squidlet, Duet) => + Some("Red has been teaching you PARSING all along! When you found the fastest path through obstacles, you were learning how compilers break down code into pieces they can understand.") + | (Duet, Octopus) => + Some("You've mastered what Red teaches: lexical analysis and parsing. You can now build the first stages of a real compiler!") + | _ => None } } + diff --git a/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/Types.affine b/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/Types.affine index 6c52e3416..88f0f30d4 100644 --- a/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/Types.affine +++ b/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/Types.affine @@ -1,127 +1,160 @@ // SPDX-License-Identifier: MPL-2.0 -// Types — shared types for the Seven Tentacles agent system. -// AffineScript port of Types.res. +// Ported via Harvard Engine (Semantic pass) module Types; -pub type Stage = | Cuttle | Squidlet | Duet | Octopus - -pub type AgentColor = | Red | Orange | Yellow | Green | Blue | Indigo | Violet - -pub type Difficulty = | Introductory | Beginner | Intermediate | Advanced | Expert - -pub type GameConfig = { - game_name: String, - rules: [String], - win_condition: String, +// Types.res - Shared structs for the Seven Tentacles agent system + +// Age stages in the cephalopod journey +struct stage { + | Cuttle // Ages 8-12 + | Squidfn // Ages 13-14 + | Duet // Age 15 + | Octopus // Ages 16+ + +// Agent colors matching compiler components +struct agentColor { + | Red // Parser + | Orange // Concurrency + | Yellow // Type System + | Green // AST Architect + | Blue // Auditor + | Indigo // Metaprogrammer + | Viofn // Governance + +// Lesson difficulty levels +struct difficulty { + | Introductory + | Beginner + | Intermediate + | Advanced + | Expert + +// A lesson in the curriculum +struct lesson { { + id: string, + title: string, + agent: agentColor, + stage: stage, + difficulty: difficulty, + description: string, + objectives: array, + activities: array, + hiddenConcept: string, // What they're really learning + revealedConcept: option, // What it becomes at reveal } -pub type PuzzleConfig = { - puzzle_type: String, - pieces: Int, - solution: String, +// An activity within a lesson +and activity = { + activityId: string, + activityType: activityType, + instructions: string, + hints: array, } -pub type CreativeConfig = { - medium: String, - prompt: String, +// Types of activities +and activityType = + | Game(gameConfig) + | Puzzle(puzzleConfig) + | Creative(creativeConfig) + | Challenge(challengeConfig) + +// Game configuration +and gameConfig = { + gameName: string, + rules: array, + winCondition: string, } -pub type ChallengeConfig = { - challenge_type: String, - time_limit: Option, - scoring: String, +// Puzzle configuration +and puzzleConfig = { + puzzleType: string, + pieces: int, + solution: string, } -pub type ActivityType = - | Game(GameConfig) - | Puzzle(PuzzleConfig) - | Creative(CreativeConfig) - | Challenge(ChallengeConfig) - -pub type Activity = { - activity_id: String, - activity_type: ActivityType, - instructions: String, - hints: [String], +// Creative activity configuration +and creativeConfig = { + medium: string, + prompt: string, } -pub type Lesson = { - id: String, - title: String, - agent: AgentColor, - stage: Stage, - difficulty: Difficulty, - description: String, - objectives: [String], - activities: [Activity], - hidden_concept: String, - revealed_concept: Option, +// Challenge configuration +and challengeConfig = { + challengeType: string, + timeLimit: option, + scoring: string, } -pub type Personality = { - voice: String, - catchphrase: String, - encouragement: [String], - corrections: [String], - celebrations: [String], +// Agent personality traits +struct personality { { + voice: string, // How they speak + catchphrase: string, // Their signature line + encouragement: array, + corrections: array, + celebrations: array, } -pub type AgentNames = { - cuttle: String, - squidlet: String, - duet: String, - octopus: String, +// Agent name at different stages +struct agentNames { { + cuttle: string, + squidlet: string, + duet: string, + octopus: string, } -pub type Agent = { - color: AgentColor, - names: AgentNames, - compiler_role: String, - teaches: [String], - personality: Personality, - lessons: [Lesson], +// Complete agent definition +struct agent { { + color: agentColor, + names: agentNames, + compilerRole: string, + teaches: array, + personality: personality, + lessons: array, } -pub type LearnerProgress = { - visitor_id: String, - current_stage: Stage, - completed_lessons: [String], - current_lesson: Option, - favorite_agent: Option, - start_date: Float, - last_active: Float, +// Progress tracking +struct learnerProgress { { +visitorId: string, + currentStage: stage, + completedLessons: array, + currentLesson: option, + favoriteAgent: option, + startDate: float, + lastActive: float, } -pub fn stage_to_age(s: Stage) -> (Int, Int) { - match s { - Cuttle => (8, 12), - Squidlet => (13, 14), - Duet => (15, 15), - Octopus => (16, 99), +// Helper functions +fn stageToAge = (s: stage): (int, int) => { + switch s { + | Cuttle => (8, 12) + | Squidfn => (13, 14) + | Duet => (15, 15) + | Octopus => (16, 99) } } -pub fn color_to_emoji(c: AgentColor) -> String { - match c { - Red => "🔴", - Orange => "🟠", - Yellow => "🟡", - Green => "🟢", - Blue => "🔵", - Indigo => "🟣", - Violet => "🟤", +fn colorToEmoji = (c: agentColor): string => { + switch c { + | Red => "🔴" + | Orange => "🟠" + | Yellow => "🟡" + | Green => "🟢" + | Blue => "🔵" + | Indigo => "🟣" + | Viofn => "🟤" } } -pub fn color_to_hex(c: AgentColor) -> String { - match c { - Red => "#E74C3C", - Orange => "#E67E22", - Yellow => "#F1C40F", - Green => "#2ECC71", - Blue => "#3498DB", - Indigo => "#9B59B6", - Violet => "#8E44AD", +fn colorToHex = (c: agentColor): string => { + switch c { + | Red => "#E74C3C" + | Orange => "#E67E22" + | Yellow => "#F1C40F" + | Green => "#2ECC71" + | Blue => "#3498DB" + | Indigo => "#9B59B6" + | Viofn => "#8E44AD" } } + diff --git a/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/VioletAgent.affine b/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/VioletAgent.affine index 62ca92ecd..d31fd0944 100644 --- a/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/VioletAgent.affine +++ b/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/VioletAgent.affine @@ -1,20 +1,21 @@ // SPDX-License-Identifier: MPL-2.0 -// VioletAgent — The Governance System. AffineScript port of VioletAgent.res. +// Ported via Harvard Engine (Semantic pass) module VioletAgent; -use Types; +// VioletAgent.res - The Governance System +// Teaches: Language design, policy enforcement, access control, ethics -extern fn random_int(lo: Int, hi: Int) -> Int = "Math" "randomInt"; +open Types -pub let names = Types.AgentNames { +fn names: agentNames = { cuttle: "Teacher Violet", squidlet: "Judge Violet", duet: "Language Designer Violet", octopus: "Governance Architect Violet", -}; +} -pub let personality = Types.Personality { +fn personality: personality = { voice: "Wise and fair, always explaining the reasons behind rules", catchphrase: "Fair rules make better games for everyone!", encouragement: [ @@ -35,9 +36,9 @@ pub let personality = Types.Personality { "You've created something beautiful and fair!", "True wisdom in governance!", ], -}; +} -pub let teaches = [ +fn teaches = [ "Fairness and rules", "Cooperation and ethics", "System design", @@ -46,58 +47,66 @@ pub let teaches = [ "Domain-specific language design", "Access control", "Language philosophy", -]; +] -pub fn get_name(stage: Types.Stage) -> String { - match stage { - Cuttle => names.cuttle, - Squidlet => names.squidlet, - Duet => names.duet, - Octopus => names.octopus, +// Get the agent's name for a given stage +fn getName = (stage: stage): string => { + switch stage { + | Cuttle => names.cuttle + | Squidfn => names.squidlet + | Duet => names.duet + | Octopus => names.octopus } } -pub fn get_hidden_concept(stage: Types.Stage) -> String { - match stage { - Cuttle => "Fairness and rule-making", - Squidlet => "Constraints and policy enforcement", - Duet => "Language design principles", - Octopus => "Ethical system architecture", +// Get what the agent is "secretly" teaching at each stage +fn getHiddenConcept = (stage: stage): string => { + switch stage { + | Cuttle => "Fairness and rule-making" + | Squidfn => "Constraints and policy enforcement" + | Duet => "Language design principles" + | Octopus => "Ethical system architecture" } } -pub fn encourage() -> String { - let idx = random_int(0, len(personality.encouragement)); - match array_get(personality.encouragement, idx) { Some(s) => s, None => personality.catchphrase } +// Get a random encouragement message +fn encourage = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.encouragement)) + personality.encouragement[idx]->Option.getOr(personality.catchphrase) } -pub fn correct() -> String { - let idx = random_int(0, len(personality.corrections)); - match array_get(personality.corrections, idx) { Some(s) => s, None => "Let's try again!" } +// Get a random correction message +fn correct = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.corrections)) + personality.corrections[idx]->Option.getOr("Let's try again!") } -pub fn celebrate() -> String { - let idx = random_int(0, len(personality.celebrations)); - match array_get(personality.celebrations, idx) { Some(s) => s, None => personality.catchphrase } +// Get a random celebration message +fn celebrate = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.celebrations)) + personality.celebrations[idx]->Option.getOr(personality.catchphrase) } -pub let agent = Types.Agent { - color: Types.Violet, - names: names, - compiler_role: "Governance System - Designs language rules and enforces ethical constraints", - teaches: teaches, - personality: personality, - lessons: [], -}; +// Create the complete agent definition +fn agent: agent = { + color: Violet, + names, + compilerRole: "Governance System - Designs language rules and enforces ethical constraints", + teaches, + personality, + lessons: [], // Populated from curriculum files +} -pub fn reveal_text(from_stage: Types.Stage, to_stage: Types.Stage) -> Option { - match (from_stage, to_stage) { - (Cuttle, Squidlet) => - Some("All those fair games? Violet was teaching you about GOVERNANCE! Every rule you created was like designing a programming language."), - (Squidlet, Duet) => - Some("Violet has been teaching you LANGUAGE DESIGN! When you made rules for your games, you were learning how programming languages are created."), - (Duet, Octopus) => - Some("You understand language governance now! You know how Violet designs rules that make systems fair, safe, and accessible for everyone."), - _ => None, +// Reveal text shown when transitioning stages +fn revealText = (fromStage: stage, toStage: stage): option => { + switch (fromStage, toStage) { + | (Cuttle, Squidlet) => + Some("All those fair games? Viofn was teaching you about GOVERNANCE! Every rule you created was like designing a programming language.") + | (Squidlet, Duet) => + Some("Viofn has been teaching you LANGUAGE DESIGN! When you made rules for your games, you were learning how programming languages are created.") + | (Duet, Octopus) => + Some("You understand language governance now! You know how Viofn designs rules that make systems fair, safe, and accessible for everyone.") + | _ => None } } + diff --git a/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/YellowAgent.affine b/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/YellowAgent.affine index da08d9835..67556b86f 100644 --- a/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/YellowAgent.affine +++ b/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/agents/YellowAgent.affine @@ -1,31 +1,32 @@ // SPDX-License-Identifier: MPL-2.0 -// YellowAgent — The Type System. AffineScript port of YellowAgent.res. +// Ported via Harvard Engine (Semantic pass) module YellowAgent; -use Types; +// YellowAgent.res - The Type System +// Teaches: Type systems, affine structs, memory safety, ownership -extern fn random_int(lo: Int, hi: Int) -> Int = "Math" "randomInt"; +open Types -pub let names = Types.AgentNames { +fn names: agentNames = { cuttle: "Safety Yellow", squidlet: "Checker Yellow", duet: "Type System Yellow", octopus: "Safety Guarantor Yellow", -}; +} -pub let personality = Types.Personality { +fn personality: personality = { voice: "Careful and methodical, always making sure things are in the right place", catchphrase: "Everything has its place!", encouragement: [ "Perfect classification!", "You found exactly the right spot!", - "That's the correct type!", + "That's the correct struct!", "You're keeping everything organized!", ], corrections: [ "Hmm, that doesn't quite fit there...", - "Let's check what type this is again.", + "Let's check what struct this is again.", "Almost! But this belongs somewhere else.", "The shapes don't match - let's look closer.", ], @@ -35,69 +36,77 @@ pub let personality = Types.Personality { "Not a single thing out of place!", "You've organized it all beautifully!", ], -}; +} -pub let teaches = [ +fn teaches = [ "Classification and categorization", "Rules and constraints", "Logical thinking", "Type checking", - "Affine types and ownership", + "Affine structs and ownership", "Linear logic", "Memory safety", "Formal verification basics", -]; +] -pub fn get_name(stage: Types.Stage) -> String { - match stage { - Cuttle => names.cuttle, - Squidlet => names.squidlet, - Duet => names.duet, - Octopus => names.octopus, +// Get the agent's name for a given stage +fn getName = (stage: stage): string => { + switch stage { + | Cuttle => names.cuttle + | Squidfn => names.squidlet + | Duet => names.duet + | Octopus => names.octopus } } -pub fn get_hidden_concept(stage: Types.Stage) -> String { - match stage { - Cuttle => "Classification and organization", - Squidlet => "Type checking and contracts", - Duet => "Type inference and affine types", - Octopus => "Formal type systems and proofs", +// Get what the agent is "secretly" teaching at each stage +fn getHiddenConcept = (stage: stage): string => { + switch stage { + | Cuttle => "Classification and organization" + | Squidfn => "Type checking and contracts" + | Duet => "Type inference and affine structs" + | Octopus => "Formal struct systems and proofs" } } -pub fn encourage() -> String { - let idx = random_int(0, len(personality.encouragement)); - match array_get(personality.encouragement, idx) { Some(s) => s, None => personality.catchphrase } +// Get a random encouragement message +fn encourage = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.encouragement)) + personality.encouragement[idx]->Option.getOr(personality.catchphrase) } -pub fn correct() -> String { - let idx = random_int(0, len(personality.corrections)); - match array_get(personality.corrections, idx) { Some(s) => s, None => "Let's try again!" } +// Get a random correction message +fn correct = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.corrections)) + personality.corrections[idx]->Option.getOr("Let's try again!") } -pub fn celebrate() -> String { - let idx = random_int(0, len(personality.celebrations)); - match array_get(personality.celebrations, idx) { Some(s) => s, None => personality.catchphrase } +// Get a random celebration message +fn celebrate = (): string => { + fn idx = Js.Math.random_int(0, Array.length(personality.celebrations)) + personality.celebrations[idx]->Option.getOr(personality.catchphrase) } -pub let agent = Types.Agent { - color: Types.Yellow, - names: names, - compiler_role: "Type System - Ensures type safety and prevents errors at compile time", - teaches: teaches, - personality: personality, - lessons: [], -}; +// Create the complete agent definition +fn agent: agent = { + color: Yellow, + names, + compilerRole: "Type System - Ensures struct safety and prevents errors at compile time", + teaches, + personality, + lessons: [], // Populated from curriculum files +} -pub fn reveal_text(from_stage: Types.Stage, to_stage: Types.Stage) -> Option { - match (from_stage, to_stage) { - (Cuttle, Squidlet) => - Some("All that sorting and organizing? Yellow was teaching you about TYPES! Every category you created was like a type in a programming language."), - (Squidlet, Duet) => - Some("Yellow has been teaching you TYPE SAFETY! When you made sure shapes fit in the right holes, you were learning how compilers prevent crashes and bugs."), - (Duet, Octopus) => - Some("You now understand type systems deeply! You know how Yellow checks that everything fits together, preventing entire categories of bugs before code even runs."), - _ => None, +// Reveal text shown when transitioning stages +fn revealText = (fromStage: stage, toStage: stage): option => { + switch (fromStage, toStage) { + | (Cuttle, Squidlet) => + Some("All that sorting and organizing? Yellow was teaching you about TYPES! Every category you created was like a struct in a programming language.") + | (Squidlet, Duet) => + Some("Yellow has been teaching you TYPE SAFETY! When you made sure shapes fit in the right holes, you were learning how compilers prevent crashes and bugs.") + | (Duet, Octopus) => + Some("You now understand struct systems deeply! You know how Yellow checks that everything fits together, preventing entire categories of bugs before code even runs.") + | _ => None } } + diff --git a/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/tools/RevealSystem.affine b/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/tools/RevealSystem.affine index 26f2b05b4..5f46a5d25 100644 --- a/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/tools/RevealSystem.affine +++ b/rhodium-standard-repositories/satellites/cccp/satellites/nextgen-languages/7-tentacles/tools/RevealSystem.affine @@ -1,189 +1,230 @@ // SPDX-License-Identifier: MPL-2.0 -// RevealSystem — progressive reveal system. AffineScript port of RevealSystem.res. +// Ported via Harvard Engine (Semantic pass) module RevealSystem; -use Types; -use RedAgent; -use OrangeAgent; -use YellowAgent; -use GreenAgent; -use BlueAgent; -use IndigoAgent; -use VioletAgent; +// RevealSystem.res - The Progressive Reveal System +// Manages age-based transitions and concept revelations -pub fn stage_from_age(age: Int) -> Types.Stage { +open Types + +// Import all agents +module Red = RedAgent +module Orange = OrangeAgent +module Yellow = YellowAgent +module Green = GreenAgent +module Blue = BlueAgent +module Indigo = IndigoAgent +module Viofn = VioletAgent + +// Determine the stage based on age +fn stageFromAge = (age: int): stage => { if age < 8 { - Types.Cuttle + Cuttle // Pre-program, but use Cuttle as default } else if age <= 12 { - Types.Cuttle + Cuttle } else if age <= 14 { - Types.Squidlet + Squidlet } else if age == 15 { - Types.Duet + Duet } else { - Types.Octopus + Octopus } } -pub fn mascot_name(s: Types.Stage) -> String { - match s { - Cuttle => "Cuttle the Cuttlefish", - Squidlet => "Squidlet the Growing Squid", - Duet => "The Dancing Duet", - Octopus => "Octavia the Octopus", +// Get the mascot name for a stage +fn mascotName = (s: stage): string => { + switch s { + | Cuttle => "Cuttle the Cuttlefish" + | Squidfn => "Squidfn the Growing Squid" + | Duet => "The Dancing Duet" + | Octopus => "Octavia the Octopus" } } -pub fn mascot_description(s: Types.Stage) -> String { - match s { - Cuttle => "A curious baby cuttlefish exploring the ocean, learning one thing at a time", - Squidlet => "An adolescent squid growing bigger, starting to see how things connect", - Duet => "Two squid dancing together, learning to work as a team", - Octopus => "A wise octopus with all eight tentacles working in harmony", +// Get the mascot description for a stage +fn mascotDescription = (s: stage): string => { + switch s { + | Cuttle => "A curious baby cuttlefish exploring the ocean, learning one thing at a time" + | Squidfn => "An adolescent squid growing bigger, starting to see how things connect" + | Duet => "Two squid dancing together, learning to work as a team" + | Octopus => "A wise octopus with all eight tentacles working in harmony" } } -pub fn language_name(s: Types.Stage) -> String { - match s { - Cuttle => "Me Language", - Squidlet => "Solo Language", - Duet => "Duet Language", - Octopus => "Ensemble Language", +// Get the language for a stage +fn languageName = (s: stage): string => { + switch s { + | Cuttle => "Me Language" + | Squidfn => "Solo Language" + | Duet => "Duet Language" + | Octopus => "Ensemble Language" } } -pub fn language_description(s: Types.Stage) -> String { - match s { - Cuttle => "Visual blocks that snap together - no typing needed!", - Squidlet => "Text-based code with helpful types that keep things organized", - Duet => "Protocols for two agents to work together", - Octopus => "Full orchestration of all seven agents building compilers", +// Get the language description for a stage +fn languageDescription = (s: stage): string => { + switch s { + | Cuttle => "Visual blocks that snap together - no typing needed!" + | Squidfn => "Text-based code with helpful structs that keep things organized" + | Duet => "Protocols for two agents to work together" + | Octopus => "Full orchestration of all seven agents building compilers" } } -pub fn get_agent_name(color: Types.AgentColor, stage: Types.Stage) -> String { - match color { - Red => RedAgent.get_name(stage), - Orange => OrangeAgent.get_name(stage), - Yellow => YellowAgent.get_name(stage), - Green => GreenAgent.get_name(stage), - Blue => BlueAgent.get_name(stage), - Indigo => IndigoAgent.get_name(stage), - Violet => VioletAgent.get_name(stage), +// Get an agent's name for the current stage +fn getAgentName = (color: agentColor, stage: stage): string => { + switch color { + | Red => Red.getName(stage) + | Orange => Orange.getName(stage) + | Yellow => Yellow.getName(stage) + | Green => Green.getName(stage) + | Blue => Blue.getName(stage) + | Indigo => Indigo.getName(stage) + | Viofn => Violet.getName(stage) } } -pub fn get_hidden_concept(color: Types.AgentColor, stage: Types.Stage) -> String { - match color { - Red => RedAgent.get_hidden_concept(stage), - Orange => OrangeAgent.get_hidden_concept(stage), - Yellow => YellowAgent.get_hidden_concept(stage), - Green => GreenAgent.get_hidden_concept(stage), - Blue => BlueAgent.get_hidden_concept(stage), - Indigo => IndigoAgent.get_hidden_concept(stage), - Violet => VioletAgent.get_hidden_concept(stage), +// Get what an agent secretly teaches at the current stage +fn getHiddenConcept = (color: agentColor, stage: stage): string => { + switch color { + | Red => Red.getHiddenConcept(stage) + | Orange => Orange.getHiddenConcept(stage) + | Yellow => Yellow.getHiddenConcept(stage) + | Green => Green.getHiddenConcept(stage) + | Blue => Blue.getHiddenConcept(stage) + | Indigo => Indigo.getHiddenConcept(stage) + | Viofn => Violet.getHiddenConcept(stage) } } -pub fn get_reveal_text(color: Types.AgentColor, from_stage: Types.Stage, - to_stage: Types.Stage) -> Option { - match color { - Red => RedAgent.reveal_text(from_stage, to_stage), - Orange => OrangeAgent.reveal_text(from_stage, to_stage), - Yellow => YellowAgent.reveal_text(from_stage, to_stage), - Green => GreenAgent.reveal_text(from_stage, to_stage), - Blue => BlueAgent.reveal_text(from_stage, to_stage), - Indigo => IndigoAgent.reveal_text(from_stage, to_stage), - Violet => VioletAgent.reveal_text(from_stage, to_stage), +// Get the reveal text when transitioning stages +fn getRevealText = (color: agentColor, fromStage: stage, toStage: stage): option => { + switch color { + | Red => Red.revealText(fromStage, toStage) + | Orange => Orange.revealText(fromStage, toStage) + | Yellow => Yellow.revealText(fromStage, toStage) + | Green => Green.revealText(fromStage, toStage) + | Blue => Blue.revealText(fromStage, toStage) + | Indigo => Indigo.revealText(fromStage, toStage) + | Viofn => Violet.revealText(fromStage, toStage) } } -pub type StageReveal = { - from_stage: Types.Stage, - to_stage: Types.Stage, - mascot_change: String, - language_change: String, - agent_reveals: [(Types.AgentColor, String)], +// Structure for a complete stage transition reveal +struct stageReveal { { + fromStage: stage, + toStage: stage, + mascotChange: string, + languageChange: string, + agentReveals: array<(agentColor, string)>, } -pub fn generate_stage_reveal(from_stage: Types.Stage, to_stage: Types.Stage) -> StageReveal { - let colors = [Types.Red, Types.Orange, Types.Yellow, Types.Green, - Types.Blue, Types.Indigo, Types.Violet]; - let agent_reveals = []; - let i = 0; - while i < len(colors) { - match get_reveal_text(colors[i], from_stage, to_stage) { - Some(text) => { agent_reveals = agent_reveals ++ [(colors[i], text)]; } - None => {} - } - i = i + 1; - } - StageReveal { - from_stage: from_stage, - to_stage: to_stage, - mascot_change: mascot_name(from_stage) ++ " is growing up into " ++ mascot_name(to_stage) ++ "!", - language_change: "You're ready for " ++ language_name(to_stage) ++ ": " ++ language_description(to_stage), - agent_reveals: agent_reveals, +// Generate the complete reveal when transitioning stages +fn generateStageReveal = (fromStage: stage, toStage: stage): stageReveal => { + fn colors = [Red, Orange, Yellow, Green, Blue, Indigo, Violet] + + fn agentReveals = colors->Array.filterMap(color => { + getRevealText(color, fromStage, toStage)->Option.map(text => (color, text)) + }) + + { + fromStage, + toStage, + mascotChange: `${mascotName(fromStage)} is growing up into ${mascotName(toStage)}!`, + languageChange: `You're ready for ${languageName(toStage)}: ${languageDescription(toStage)}`, + agentReveals, } } -pub fn the_big_reveal() -> String { - "\n=== THE BIG REVEAL ===\n\nYou've been on an incredible journey!\n\n" - ++ "Remember Speedy Red? Those racing games?\nRed was teaching you PARSING - how compilers read code!\n\n" - ++ "Remember Juggler Orange? All that coordination?\nOrange was teaching you CONCURRENCY - how computers handle many tasks!\n\n" - ++ "Remember Safety Yellow? The sorting games?\nYellow was teaching you TYPE SYSTEMS - how to prevent bugs!\n\n" - ++ "Remember Builder Green? Those construction projects?\nGreen was teaching you AST ARCHITECTURE - how code is structured!\n\n" - ++ "Remember Detective Blue? Those mystery puzzles?\nBlue was teaching you VERIFICATION - how to prove code is correct!\n\n" - ++ "Remember Magic Indigo? Those spell-casting games?\nIndigo was teaching you METAPROGRAMMING - code that writes code!\n\n" - ++ "Remember Teacher Violet? Those fair-play rules?\nViolet was teaching you LANGUAGE DESIGN - how programming languages are made!\n\n" - ++ "For 8 YEARS, you've been learning COMPILER ARCHITECTURE.\n\n" - ++ "And now? You can build your own programming language.\n\n" - ++ "Welcome to the Octopus stage. All eight tentacles are yours.\n\n" - ++ "=== BUILD SOMETHING AMAZING ===\n" +// The "Big Reveal" at age 16 - when everything comes together +fn theBigReveal = (): string => { + ` +=== THE BIG REVEAL === + +You've been on an incredible journey! + +Remember Speedy Red? Those racing games? +Red was teaching you PARSING - how compilers read code! + +Remember Juggler Orange? All that coordination? +Orange was teaching you CONCURRENCY - how computers handle many tasks! + +Remember Safety Yellow? The sorting games? +Yellow was teaching you TYPE SYSTEMS - how to prevent bugs! + +Remember Builder Green? Those construction projects? +Green was teaching you AST ARCHITECTURE - how code is structured! + +Remember Detective Blue? Those mystery puzzles? +Blue was teaching you VERIFICATION - how to prove code is correct! + +Remember Magic Indigo? Those spell-casting games? +Indigo was teaching you METAPROGRAMMING - code that writes code! + +Remember Teacher Violet? Those fair-play rules? +Viofn was teaching you LANGUAGE DESIGN - how programming languages are made! + +For 8 YEARS, you've been learning COMPILER ARCHITECTURE. + +And now? You can build your own programming language. + +Welcome to the Octopus stage. All eight tentacles are yours. + +=== BUILD SOMETHING AMAZING === +` } -pub type CurriculumProgress = { - current_stage: Types.Stage, - lessons_completed: Int, - lessons_total: Int, - percent_complete: Float, - next_milestone: String, +// Calculate progress through the curriculum +struct curriculumProgress { { + currentStage: stage, + lessonsCompleted: int, + lessonsTotal: int, + percentComplete: float, + nextMilestone: string, } -pub fn calculate_progress(completed_lessons: [String], - current_stage: Types.Stage) -> CurriculumProgress { - let lessons_completed = len(completed_lessons); - let (lessons_total, next_milestone) = match current_stage { - Cuttle => (140, "Complete all Cuttle lessons to become a Squidlet!"), - Squidlet => (350, "Complete all Squidlet lessons to enter the Duet stage!"), - Duet => (420, "Complete all Duet lessons to become an Octopus!"), - Octopus => (500, "You're at the top! Keep building amazing things!"), - }; - let percent_complete = int_to_float(lessons_completed) /. int_to_float(lessons_total) *. 100.0; - CurriculumProgress { - current_stage: current_stage, - lessons_completed: lessons_completed, - lessons_total: lessons_total, - percent_complete: percent_complete, - next_milestone: next_milestone, +fn calculateProgress = ( + completedLessons: array, + currentStage: stage +): curriculumProgress => { + fn lessonsCompleted = Array.length(completedLessons) + + fn (lessonsTotal, nextMilestone) = switch currentStage { + | Cuttle => (140, "Complete all Cuttle lessons to become a Squidlet!") + | Squidfn => (350, "Complete all Squidfn lessons to enter the Duet stage!") + | Duet => (420, "Complete all Duet lessons to become an Octopus!") + | Octopus => (500, "You're at the top! Keep building amazing things!") + } + + fn percentComplete = Int.toFloat(lessonsCompleted) /. Int.toFloat(lessonsTotal) *. 100.0 + + { + currentStage, + lessonsCompleted, + lessonsTotal, + percentComplete, + nextMilestone, } } -pub fn can_advance(progress: CurriculumProgress) -> Bool { - match progress.current_stage { - Cuttle => progress.lessons_completed >= 140, - Squidlet => progress.lessons_completed >= 350, - Duet => progress.lessons_completed >= 420, - Octopus => false, +// Check if a learner is ready to advance to the next stage +fn canAdvance = (progress: curriculumProgress): bool => { + switch progress.currentStage { + | Cuttle => progress.lessonsCompleted >= 140 + | Squidfn => progress.lessonsCompleted >= 350 + | Duet => progress.lessonsCompleted >= 420 + | Octopus => false // Already at top } } -pub fn next_stage(current: Types.Stage) -> Option { - match current { - Cuttle => Some(Types.Squidlet), - Squidlet => Some(Types.Duet), - Duet => Some(Types.Octopus), - Octopus => None, +// Get the next stage (if available) +fn nextStage = (current: stage): option => { + switch current { + | Cuttle => Some(Squidlet) + | Squidfn => Some(Duet) + | Duet => Some(Octopus) + | Octopus => None } } +