dot_claude/skills/gleam-practice/SKILL.md
Best practices for building and reviewing Gleam projects on the Erlang target, especially Wisp plus Mist web services, OTP processes, justfile workflows, testing, formatting, CI, and performance measurement.
npx skillsauth add mizchi/chezmoi-dotfiles gleam-practiceInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Gleam を新規作成するとき、既存プロジェクトを改善するとき、wisp + mist + gleam_otp + just 構成で実装するときに使う。
gleam new と gleam add を使う。依存は手書きより solver に決めさせる。探索 -> Red -> Green -> Refactor。pub opaque type を優先し、内部 constructor や protocol 型をむやみに公開しない。@external は最後の手段。使う場合は薄い adapter module に閉じ込める。wisp handler は decode, call service, encode response に寄せる。justfile に集約し、CI も just ci を叩く。Erlang target の新規 project はこれを起点にする。
gleam new my_app --template erlang
cd my_app
gleam add wisp mist gleam_otp gleam_erlang
gleam add gleam_json envoy
gleam add --dev gleeunit
必要に応じて追加する。
gleam add gleam_http gleam_httpcgleam add simplifile filepathgleam add --dev birdiegleam add --dev qcheck qcheck_gleeunit_utilsgleam remove gleeunit && gleam add --dev glaciergleam add --dev cleamgleam add --dev glycheegleam add logginggleam add --dev olive外部 CLI:
k6wisp と mist は互換性のある組み合わせを使う。version を個別に固定するより、gleam add wisp mist を同じタイミングで解決させる方が安全。
雛形:
assets/README.mdassets/justfileassets/github-actions/ci.ymlassets/bench/http.jsassets/test/snapshot_test.gleamassets/test/property_test.gleamassets/test/qcheck_parallel_test.gleamassets/test/timeout_test.gleam最低限これを埋める。
name = "my_app"
version = "0.1.0"
target = "erlang"
description = "Short package summary"
licences = ["Apache-2.0"]
repository = { type = "github", user = "owner", repo = "repo" }
# Package 内だけで使う module は隠す
internal_modules = [
"my_app/internal",
"my_app/http/internal",
]
原則:
internal_modules で隠す最初はこの分け方から始める。
src/
my_app.gleam # entrypoint
my_app/app.gleam # DI と supervision 起動
my_app/web.gleam # router / request handling
my_app/domain.gleam # pure domain logic
my_app/domain_server.gleam # actor wrapper
my_app/http_json.gleam # encoder / decoder
test/
my_app_test.gleam
docs/
reference-ja.md
reference-en.md
bench/
main.gleam
justfile
分離の基準:
domain.gleam: pure function のみ*_server.gleam: actor / supervision / timeout / mailboxweb.gleam: routing と HTTP mappinghttp_json.gleam: JSON schema と codecapp.gleam: wiring のみ1 file に router, JSON codec, business logic, actor state を混ぜない。
大きくなったらこう分ける。
web_*.gleam: feature ごとの handlerweb_json_*.gleam: domain ごとの encoder / decoderagent_*.gleam: protocol, runner, tool, transportworkspace_*.gleam: overlay, patch, git, session, runtime*_test_support.gleam: test helper を責務ごとに分離src/my_app.gleam
import envoy
import gleam/erlang/process
import gleam/int
import gleam/result
import mist
import my_app/app
import my_app/web
import wisp/wisp_mist
const default_port = 4000
const default_secret_key_base =
"local-dev-secret-key-base-local-dev-secret-key-base-1234567890"
pub fn main() -> Nil {
let port =
envoy.get("PORT")
|> result.map(int.parse)
|> result.flatten
|> result.unwrap(default_port)
let secret_key_base =
envoy.get("SECRET_KEY_BASE")
|> result.unwrap(default_secret_key_base)
let assert Ok(app_state) = app.start()
let assert Ok(_) =
web.app(app_state)
|> wisp_mist.handler(secret_key_base)
|> mist.new
|> mist.bind("0.0.0.0")
|> mist.port(port)
|> mist.start
process.sleep_forever()
}
src/my_app/web.gleam
import gleam/http
import my_app/app.{type App}
import wisp
pub fn app(app: App) -> fn(wisp.Request) -> wisp.Response {
fn(request) {
case request.method, wisp.path_segments(request) {
http.Get, ["healthz"] -> wisp.text_response(200, "ok\n")
_, _ -> wisp.not_found()
}
}
}
web 層の原則:
wisp.require_json で payload を読む4004xx/5xx へ明示的に map する基本は pure state と server の 2 層。
重要: gleam_otp は Erlang gen_server を静的型付けで包んだライブラリで、API は gen_server と非互換。:gen_server.call / :gen_server.cast を直接呼ばない。Subject と actor.Message の世界で完結させる。
pub opaque type State {
State(hits: Dict(String, Int), limit: Int)
}
pub fn new(limit: Int) -> State {
State(hits: dict.new(), limit: limit)
}
pub fn incr(state: State, key: String) -> #(State, Bool) {
let n = dict.get(state.hits, key) |> result.unwrap(0) + 1
#(State(..state, hits: dict.insert(state.hits, key, n)), n <= state.limit)
}
import gleam/erlang/process.{type Subject}
import gleam/otp/actor
import gleam/otp/supervision
pub opaque type Message {
Check(key: String, reply_to: Subject(Bool))
Reset(key: String)
}
pub opaque type Server { Server(subject: Subject(Message)) }
pub fn start(limit: Int) -> Result(Server, actor.StartError) {
actor.new(new(limit))
|> actor.on_message(handle)
|> actor.start
|> result.map(fn(started) { Server(subject: started.data) })
}
pub fn supervised(limit: Int) -> supervision.ChildSpecification(Server) {
supervision.worker(fn() { start(limit) })
}
fn handle(state: State, msg: Message) -> actor.Next(State, Message) {
case msg {
Check(key, reply_to) -> {
let #(next, ok) = incr(state, key)
process.send(reply_to, ok)
actor.continue(next)
}
Reset(key) ->
actor.continue(State(..state, hits: dict.delete(state.hits, key)))
}
}
// sync call pattern: 呼び出し側が reply subject を作って渡す
pub fn check(s: Server, key: String) -> Bool {
let reply = process.new_subject()
process.send(s.subject, Check(key, reply))
process.receive(reply, 1000) |> result.unwrap(False)
}
pub fn reset(s: Server, key: String) -> Nil {
process.send(s.subject, Reset(key))
}
gleam_otp は supervisor を用途別に 3 モジュールに分けている。
| 種類 | モジュール | いつ使う |
|---|---|---|
| static | gleam/otp/static_supervisor | 起動時に子プロセスが確定(DB pool、設定済み actor)。再起動だけを扱う |
| factory | gleam/otp/supervisor_factory | 同一 spec を動的に ID 付きで増やす(session per user 等)。start_child(name, arg) |
| dynamic | gleam/otp/supervisor_dynamic | 任意の spec を動的に追加・削除(job worker pool 等)。start_child(name, spec) / terminate_child |
実際の API 名はバージョンで揺れる。gleam_otp 公式 hex docs で都度確認。以下は v0.16+ 前提の雛形:
// src/my_app/app.gleam
import gleam/otp/static_supervisor as sup
import my_app/db_pool
import my_app/rate_limiter
pub fn start() -> Result(Nil, actor.StartError) {
sup.new(sup.OneForOne)
|> sup.restart_tolerance(intensity: 3, period: 60) // 60s で 3 回までの再起動を許容
|> sup.add(db_pool.supervised()) // static child
|> sup.add(rate_limiter.supervised(limit: 100)) // static child
|> sup.start
|> result.replace(Nil)
}
restart strategy の選び方:
Permanent: 落ちたら必ず再起動(DB pool、認証サーバーなど基盤)Transient: 正常終了は OK、異常終了のみ再起動(job worker)Temporary: 再起動しない(一度だけの処理)restart_intensity(デフォルト 3)と period(デフォルト 5 秒)を超えた場合、supervisor 自身が落ちて上位に伝播する。
Server は opaque にする(外部から Subject を直接操作させない)app.gleam に集めるOneForOne、最上位は static_supervisorstatic(固定)、factory(同 spec 動的増)、dynamic(任意 spec 動的)gleam/dynamic/decode + gleam/json)Gleam の JSON API はバージョンで変わりやすい(gleam_json v2+)。現行の idiomatic な書き方:
import gleam/dynamic/decode
import gleam/json
pub type NewTodo { NewTodo(title: String, priority: Int) }
pub fn new_todo_decoder() -> decode.Decoder(NewTodo) {
use title <- decode.field("title", decode.string)
use priority <- decode.optional_field("priority", 0, decode.int)
decode.success(NewTodo(title:, priority:))
}
// Wisp handler 内で
case decode.run(body, new_todo_decoder()) {
Ok(nt) -> // ... use nt
Error(errors) -> wisp.bad_request("invalid json: " <> string.inspect(errors))
}
decode.field は必須、decode.optional_field(key, default, decoder) はオプション。nested object は decode.field("user", user_decoder())。
pub fn encode_todo(t: Todo) -> json.Json {
json.object([
#("id", json.int(t.id)),
#("title", json.string(t.title)),
#("done", json.bool(t.done)),
])
}
// Wisp で返す
wisp.json_response(json.to_string_tree(encode_todo(t)), 201)
リスト: json.array(items, of: encode_todo)。
LSP Code Action: gleam-language-server v1.2+ には「Generate JSON encoder/decoder」機能がある。Todo 型にカーソルを置いて Code Action を呼ぶと encode_todo / todo_decoder を自動生成。手書きより正確。
再利用するなら、まず assets/justfile を project root にコピーしてから微調整する。
set shell := ["bash", "-cu"]
default:
@just --list
deps:
gleam deps download
format:
gleam format
format-check:
gleam format --check .
typecheck:
gleam check
build:
gleam build --warnings-as-errors
test:
gleam test
run:
gleam run
docs:
gleam docs build
check:
gleam format --check .
gleam check
gleam build --warnings-as-errors
gleam test
ci: deps check
clean:
gleam clean
bench *args:
gleam run -m bench/main -- {{args}}
方針:
bashjust cilint 専用コマンドは作らず、format-check + build --warnings-as-errors で閉じる必要ならこれも足す。
exports: gleam run -m cleamsnapshot-review: gleam run -m birdietest-watch: gleam test -- --glacierbench-http: k6 run bench/http.jsserve-dev: gleam run -m oliveGitHub Actions を使うなら、まず assets/github-actions/ci.yml を .github/workflows/ci.yml にコピーしてから project 固有の step だけ足す。
原則:
just ci を唯一の入口にする@external, NIF, Wasm, Elixir 依存がある場合だけ追加 toolchain を足すjustfile 側へ寄せるまず pure function を固定し、そのあと actor、最後に HTTP を固定する。
test/my_app_test.gleam
import gleam/http
import gleeunit
import my_app/app
import my_app/web
import wisp/simulate
pub fn main() -> Nil {
gleeunit.main()
}
pub fn healthz_test() {
let assert Ok(app_state) = app.start()
let response = web.app(app_state)(simulate.request(http.Get, "/healthz"))
assert response.status == 200
assert simulate.read_body(response) == "ok\n"
}
テスト方針:
wisp/simulate を優先birdieqcheckglacierbirdie の基本 workflow:
gleam testgleam run -m birdieqcheck の基本 workflow:
qcheck.given(...) で generator を流すqcheck_gleeunit_utils を使うqcheck_gleeunit_utils の注意:
run.run_gleeunittest_spec.maketest_spec.make_with_timeouttest_spec.run_in_parallel / run_in_orderfeature が増えたら test file を責務で割る。
domain_test.gleam: pure domain logicruntime_test.gleam / app_test.gleam: supervision と actor integrationweb_app_test.gleam: HTTP contractagent_test.gleam: external API orchestrationworkspace_session_server_test.gleam: server / state helperworkspace_session_http_test.gleam: session API の contractworkspace_bit_runtime_test.gleam: FFI / git / wasm integrationsupport module も分ける。
*_app_test_support.gleam: app 起動、handler 作成、runtime helper*_workspace_test_support.gleam: fixture directory、workspace helper1つの巨大な test file に全部詰め込まない。refactor が進んだら test も一緒に分割する。
Gleam には first-party の独立 lint tool より、formatter と compiler warning を厳格に使う方が合う。
最低限:
gleam format --check .gleam checkgleam build --warnings-as-errorsgleam test補助:
gleam fixgleam docs buildgleam run -m cleam計測は 3 段でやる。
原則:
gleam clean と依存 download を済ませる手段:
bench/ module を作って gleam run -m bench/mainglychee を使うk6 か wrkerl / gleam shell から :eprof, :fprof, :erlang.statistics(:reductions) を使うHTTP の最小測定例:
just run
k6 run bench/http.js
最初は assets/bench/http.js を bench/http.js にコピーして使う。
@external を使うときは次を守る。
pub opaque type Handle だけ公開する@external 実装は compiler が検証しないので、Gleam 側の surface area を最小にする。
よく使う外部ツールの位置づけはこう考える。
gleeunit: 基本の unit test runner。まずこれ。birdie: snapshot test。HTTP response や generated text の固定に向く。qcheck + qcheck_gleeunit_utils: property-based test。pure domain logic に向く。glacier: interactive / incremental test loop。gleeunit の drop-in replacement として使う。cleam: 未使用 export の検出。公開 API の掃除に向く。glychee: micro benchmark。pure function や small integration の比較に向く。k6: HTTP / websocket 負荷試験。Gleam package ではなく外部 CLI。logging: Erlang logger 設定。運用寄り project なら入れてよい。olive: live reload 付き dev proxy。Wisp/Mist 開発体験を上げたいときだけ使う。使い分け:
gleam format/check/build/testbirdie, qcheck, cleam, glycheeglacier と olive は local DX を上げたいときlogging は long-running service や production app 向けREADME には少なくともこれを書く。
just ci、主要 endpoint中規模以上の project では docs/ を置き、読む順番を案内する。
docs/reference-ja.md: 日本語向けの実装ガイドdocs/reference-en.md: 英語向けの実装ガイドこの project が「入門用」なのか「実践的な参照実装」なのかを README 冒頭で明示する。
Gleam project を参照実装として見せるなら、位置づけを曖昧にしない。
wisp + mist + gleam_otp + just + CI + docs高度な題材を入れる場合は、「これは一般的な Gleam の書き方」なのか「この project 固有の複雑さ」なのかを分けて説明する。
一旦仕上げる区切りはこのあたり。
just ci が greendocs/ に参照ガイドがあるpub type を pub opaque type にできないかinternal_modules に隠すべき module はないかjust ci が local と CI の共通入口になっているかgleam build --warnings-as-errors を通しているかgleam.toml: https://gleam.run/writing-gleam/gleam-toml/tools
Use when working on github.com/mizchi/pkspec, especially release readiness, version bumps, GitHub Actions/Nix release checks, adapter DSL work, or the experimental Playwright/Vitest coverage presets. Covers the repo's spec gates, pkfire release flow, pkl CLI dependency gotchas, and what is intentionally still experimental.
data-ai
指定されたリポジトリ、複数リポジトリ、または GitHub organization から、ドメイン固有の専門用語、業界用語、社内・プロダクト用語、リポジトリ実装マップ、技術構成、オンボーディング向け Mermaid 構成図を抽出・生成するときに使う。ユーザーが「用語集を作る」「ドメイン辞書を作る」「オンボーディング資料にする」「repo/org を見て専門用語をまとめる」「AI が再確認しなくてよい知識ベースを作る」と依頼したら起動する。
development
Guide for writing MoonBit bindings to JavaScript using `extern "js"`. Use when adding FFI declarations against browser/Node/Deno APIs or npm packages, wrapping JS objects behind opaque types, bridging Promises with `async fn` and `Promise::wait()`, configuring `moon.pkg` exports for esm/cjs/iife output, or handling null/undefined at the JS boundary.
testing
技術記事の再現性 (読者が手元で再現できるか) を評価するスキル。subagent に「初見の読者として手元で再現を試みる」シミュレーションをさせ、足りない情報をリストアップさせる。記事ドラフトの最終チェック、または公開後フィードバック前の事前検証で使う。