What this project has had to understand about Crystal, beyond its manual: the points that changed a design or caused a failure. It is not a guide to Crystal. Read it before writing any: its first section is how OCX's code is written, and the guards hold every change to it.
Writing OCX's Crystal
Use the language: let the type say what the code means, and let the compiler check it (stone's review, 4bb787403b).
- Know what the compiler checks. Types are static even where they are
inferred: a value of a union type must be narrowed (
is_a?,case,if x) before it is used as one member, or the program does not compile. The compiler cannot check a cast that runs:as(T),JSON::Any#as_sand the like raise only when they run. AHash(K, V)fixes the types of its keys and values, not which keys exist (a missing one raisesKeyErrorwhen read) or which key holds which member of a union. A struct or aNamedTuplefixes both: a misspelled name does not compile. - Typed inside, JSON at the edges. Read JSON straight into a typed
element (
JSON::Serializable), where it enters; write it from one, where it leaves.JSON::Anystays at the edge that reads a message (src/ocx/mcp.cr); guard:spec/guards/typing_spec.cr. - Generate from the type. A tool's input and answer are elements
(
src/ocx/elements.cr): a macro walks each type's described fields to give its JSON, its shape, its arguments, its reading from a command line or a form, and its text and HTML. Write nothing per tool or per entry point that the type already says. to_sper element. A leaf, a value with a rule of its own, says how it reads (to_s) and parses itself. A branch writes no text of its own: its layout belongs to each entry point, which walks it (src/ocx/text.cr,src/ocx/markdown.cr). Guard:spec/guards/derivation_spec.cr.- Derive the schema; never write one. A tool's input, output and
context, and its log's operations, are types; their JSON Schemas come from
those types (
Shape.of), so what the list, help, the web page's forms and MCP's tools/list say cannot differ from what the code reads. - Name the arguments when building a value.
Step.new(does: d, outcome: o), neverStep.new(d, o, s): two arguments of one type swap unseen by position, and a misspelled name does not compile. Guard:spec/guards/typing_spec.cr, past two positional arguments. - One base exception for refusals. Everything a caller can cause raises
an
OCX::Refusal(src/ocx/refusal.cr), which every tool's answer rescues, so a refusal is answered in every entry point and nothing a caller does crashes a run. Guard:spec/guards/typing_spec.cr. - The pinned compiler, and newer ones. The gate runs the pinned
compiler (below), which cannot see what a newer one deprecates: 1.18.2
accepts
Time.monotonic, which later releases deprecate forTime.instant(seen in stone's build). Code that both accept is the rule; OCX times a run withTime.utcand shows milliseconds, which neither deprecates. Try a newer compiler before moving the pin.
The toolchain
- The compiler is pinned, and CI prints and checks the pin (precept I3,
Pinned Toolchain, in
PLAN.md). Crystal 1.18.2 has no--cache-diroption; its cache path comes fromcrystal env, and a sandbox that cannot write there needs one it can. crystal runresolvesrequirefrom the file's own directory, so a throwaway script outside the tree cannot require the tree's sources; run it from inside the tree.
Regular expressions
Crystal's Regex is PCRE2, which differs from what many expect:
\his horizontal whitespace, not a hex digit; use[0-9a-f]. A spec matching a check-in hash with\h+found nothing (0160971667).^and$match only at the ends of the string unless the pattern starts with(?m); a scan of comment lines found none without it (873fc0fb59).
Processes and the environment
- OCX reads no environment variable (precept C6, Explicit Arguments).
Configuration arrives as arguments; a child process is started with
clear_env: trueand only the variables it needs, such asHOMEandPATH. - Fossil reads its caller's environment: a CGI request's variables made
fossil cloneanswer as a web request, and withoutHOMEit could not start. Each Fossil call states its environment (666c151f57, bbbd12aa26).
Files
- Check a file through the handle that reads it. Checking a path and
then opening it lets the file change in between; OCX opens once and checks
that handle's real path
(8ef3cd9765; see
linux.md). File.realpathfollows links; compare real paths, not the paths given, to tell whether a file is inside a tree (877097e6b9).