hikari スタイル検査 (hikari lint) 仕様
本書は hikari lint の仕様を定める。起動ディスパッチ全体は hikari-command.md、言語の意味論は language-spec.md を参照。
パースは通るが書き方として問題のあるパターンを検出する。構文エラー (= 静的検査 hikari check (check.md) の領域) とは別レイヤで、スタイル/品質に絞った保守的な (誤検出を避ける) 検査を行う。check / fmt / lint の住み分けは hikari-command.md §1.2。
構文
hikari lint [--fix] [path...]
path はファイルまたはディレクトリ (ディレクトリは配下を再帰して .hikari を収集)。引数省略時は cwd を対象とする。収集ファイルはパス昇順で処理する。
--fix は自動修正可能なルールを修正してファイルを上書きする (対象は control-juxtaposition / unit-else / nested-if / trivial-match / bool-match / import-order / single-assign)。修正の際ソースは整形され (hikari fmt 相当 + 制御構造の並置化 + 条件分岐の簡素化 + import のグループ整列 + 単一代入の := 化)、内容が変わる場合のみ書き込む。--fix 指定時は診断を出力しない。
ルール
| ルール | 検出内容 |
|---|---|
unused |
値が一度も読み出されないローカル束縛。immutable 宣言 name :=、mutable local name = (auto-vivify)、分割束縛 a, b := / = / [a, b] := の各名が対象。代入先 (書き込み) は使用に数えないため write-only な mutable local も検出する。さらに body 内で一度も参照されない inner-name ({ inner self | … } の self、language-spec.md §4.2) も対象 (inner-name は body 専用の自己参照で名前による外部参照が無いため、ローカル束縛と同じく安全に判定できる)。ただし型注釈付き inner-name ({ inner self: T | … }) は名前未参照でも自身の型を表明する用途があるため対象外。破棄子 _ は対象外。オブジェクトのスロット (関数引数を含む) は対象外 (下記)。ただし export があるときの非公開トップレベル束縛は例外的に対象になる (下記「export 時の非公開トップレベル束縛」) |
self-assign |
自分自身への代入 (x = x / a.b = a.b) で効果が無い |
single-assign |
name = expr (bare identifier 左辺) で auto-vivify した mutable local (language-spec.md §6.1) が、その束縛の生存期間中に一度しか代入されず、かつ最低 1 回読み出される。実質 immutable なので immutable 宣言 name := expr で書ける (prefer-const 系。Rust unused_mut / Swift var→let / ESLint prefer-const に相当)。判定はオブジェクトリテラルを単位とするスコープ木を組み、束縛ごとに書き込みサイト (= / 複合代入 / 分解 =) を部分木全体で数える (下記)。発火条件が置換の意味保存を保証するので --fix で = を := へ自動修正する |
control-juxtaposition |
制御構造 (if / when / while) がタプル形 if(c, t, e) で書かれている。並置形 if c {t} {e} が推奨 (--fix で自動変換) |
non-tail-recur |
loop 関数 (language-spec.md §18.4) の反復ドライバ (lexically 内側の inner-name 自己再帰) で、self 呼び出しが末尾位置になく TCO (language-spec.md §18.5) が効かない。深い反復で呼び出しスタックを消費する (溢れうる)。loop は「これはループ (定数スタックで回るべき)」という意図の宣言なので、その駆動再帰が非末尾なら書き方の問題として報告する |
unit-else |
完全適用の if の else 分岐が unit だけのブロック { () }。when cond {then} が同じ意味の専用形 (language-spec.md §3.9 — when はまさに if(cond, then, { () }) の糖衣) なので when を使う (--fix で自動変換) |
nested-if |
else ブロックの唯一の文がさらに完全適用の if / when で、述語分岐が 2 本以上連なっている。hikari の if は else-if を持たず右ネストするため、平坦な述語多分岐は subjectless match の述語連鎖 (prelude.md §8.7) で書く (--fix で自動変換。末尾の else が { () } / when 終端なら catch-all 無し、通常ブロックなら _ => になる) |
trivial-match |
subjectless match の arm が「述語 1 本 (+ 任意の catch-all _)」だけで、多分岐になっていない。_ 付き (body が unit 以外) は if cond {then} {else}、_ 無しまたは _ の body が () だけなら when cond {then} で書ける (--fix で自動変換) |
bool-match |
subjectful match subject { true => … false => … } が true / false リテラルをちょうど 2 本 (順不同・guard/catch-all なし) 網羅している。true/false を明示網羅した時点で subject は Bool と意図されており、if subject {then} {else} で書ける (--fix で自動変換。true arm が then、false arm が else に対応) |
import-order |
top-level import 束縛 (body 領域・スロットリスト領域とも) が std (`std:`) → 外部パッケージ (`pkg:`) → 相対パス の順に整列していない。群内はパス文字列の辞書順、群境界は空行 1 つで区切る (--fix で自動整列)。並べ替えは抽象構文を変えるため hikari fmt ではなく --fix が担う (fmt は import をソース順のまま先頭へ hoist する)。ネストしたオブジェクト内部の import は対象外 |
検出は保守的で、検出漏れ (false negative) は許容し誤検出 (false positive) を避ける。unused はシャドウイング等の曖昧なケースでは報告しない。
条件分岐系ルール (`unit-else` / `nested-if` / `trivial-match`) の判定範囲: 判定は構文のみで行い、control-juxtaposition と同じくスコープ解決・シャドーイングは見ない。誤検出を避けるため次は対象外とする (検出漏れ側に倒す):
- 分岐がスロット付き・inner-name 付き・
extensible/conduit/loop・ブラケット形などリテラルの素のブロック ({ 文… }) でないもの (nested-ifの arm 化・trivial-matchのブロック化が意味を保存すると言えるのは素のブロックだけ)。 - 空ブロック
{}の else —{}は unit でなく自身 (空オブジェクト) を返すためwhenと等価でない。 - 引数にタプルリテラルを含む適用 — タプル spread で実効 arity が構文と食い違いうる。
- subjectful の catch-all 付き
match subject { true => …, _ => … }— 型情報なしでは subject が Bool と確定できず、非 Bool のときmatchは catch-all の実値を返し・ifは panic と挙動が割れる (_が実値を返すため差が大きい)。対してtrue/falseを両方明示網羅したmatch subject { true => … false => … }はbool-matchが対象とする — 網羅を書いた時点で subject は Bool と意図されており (非 Bool はmatchが()・ifが panic とどのみち潜在バグ、ifの panic はむしろ早期顕在化に資する)、ifへの書き換えが意味を保つ。 nested-ifの連鎖収集は arm 化できないリンク (分岐が素のブロックでない等) で打ち切り、そこまでを_ =>(catch-all) に畳む。収集できた述語が 2 本未満なら報告しない。
`non-tail-recur` を `loop` に限る理由: 非末尾の自己再帰は一般には正常であり (fact / fib・木の再帰・分割統治・アキュムレータなしのリスト構築)、それらに警告するのは誤検出になる。loop 注釈だけが「この関数はループ=定数スタックで回るべき」という意図を静的に宣言するため、その反復ドライバの非末尾 self 呼び出しに限れば高信号・低誤検出で報告できる (language-spec.md §18.5 の末尾位置判定を再利用する)。loop を使わない一般の再帰は対象外 (Scala @tailrec が注釈付き関数のみを検査するのと同じく、意図の signal がある箇所だけを見る)。
`unused` がオブジェクトのスロットを対象としない理由: スロット (関数引数を含む) は、束縛元のオブジェクトが関数として呼ばれて body 内で参照されるだけでなく、`obj.slot` で外部から参照され得る。そして hikari は型情報を静的に持たないため、ある .slot 参照がどのオブジェクトのどのスロットを指すかを hikari lint は静的に特定できない。とりわけ次のパターンでは、スロットの参照がそのファイルの外 (別ファイル) に現れる:
- ファクトリ関数が構築して return するオブジェクト のメソッド/フィールド (importer が
account.deposit等で参照) - 引数として他の関数に渡すレコード (呼び出し先が
.fieldを読む) type/enumで宣言される 型のフィールド名
これらはファイル内に .slot が現れないため、スロットを未使用検出の対象に含めると誤検出になる。name := / name = / 分割束縛といった ローカル束縛は名前で外部参照されない ので安全に判定できるが、スロットは外部 API になり得る点が本質的に異なる。この区別はエスケープ解析や型情報なしには厳密化できないため、unused はスロットを一律に対象外とする (検出漏れを許容し誤検出を避ける保守的方針)。
`export` 時の非公開トップレベル束縛 (例外): #[hikari] ファイルで export [ … ] があるとき、上記の一律対象外には例外がある。export のリストに載っていないトップレベル束縛 (Root 直下の値スロット) は、language-spec.md §13.2 (export によるファイル外公開の限定) により importer から到達不能 と保証される (丸ごと束縛越しの .slot・分解 import とも該当スロット無し)。つまりこれらの束縛は外部 API ではなくファイル私的であり、ローカル束縛や inner-name と同じ理屈で安全に未使用判定できる。ファイル内で一度も参照 (値読み出し) されない非公開トップレベル束縛は unused として報告する。
この例外は次には及ばない (対象外・検出漏れ側に倒す):
exportが無い#[hikari]ファイル (全トップレベル member が公開されるため、非公開の区別が付かない)。#{hikari}スクリプト (トップレベルは body 領域であり、通常のローカル束縛検査がそのまま担当する)。- Root 以外 (ネスト) のオブジェクト が持つスロット (そのオブジェクト自身がファイル外へ渡り得るため到達可能性を静的に確定できない。上記の一律対象外の理由がそのまま当てはまる)。
exportのリストに載っている公開スロット (from importer 到達可能)。
`single-assign` がスコープ厳密解析を行う理由と健全性: unused などが全体走査 (フラットな名前集計) で足りるのに対し、single-assign はスコープを解決しないと健全に判定できない。= は「auto-vivify (現フレームに新規 mutable local)」と「スコープチェインを遡って既存 mutable を更新」を兼ねる (language-spec.md §6.1) ため、ある name = expr を := に置換して意味が保たれるのは、それが新規束縛の宣言サイトで、かつその束縛が一度しか書かれないときに限る。そこで本ルールはオブジェクトリテラル (slot-list + body が 1 フレーム、language-spec.md §1.3) をノードとするスコープ木を組み、次を満たす名だけを報告する:
- そのスコープで auto-vivify される — スロット/パラメータ/inner-name/
:=/分解:=のいずれでも宣言されておらず、外側スコープにも同名束縛が無い (=が更新ではなく新規束縛)。 - その束縛の部分木全体での書き込み (
=/ 複合代入+=/ 分解=) がちょうど 1 回。部分木を辿る際、同名を再宣言してシャドウする内側スコープ (別束縛) には入らない。 - その束縛が最低 1 回読み出される (代入先は読み出しに数えない)。write-only は
unusedの領分なので二重報告しない。
これにより、内側クロージャがスコープチェインを遡って外側 mutable を更新する典型パターン (sum = 0 ののち xs.each { x | sum = sum + x }) は書き込み 2 回として正しく除外される (誤検出回避の要)。逆に、内側スコープが同名を自前で :=/スロット宣言して書くケースはシャドウ境界で切られ、外側の単一代入判定に干渉しない。検出漏れ側 (別スコープをまたぐ複雑な捕捉、分解 = の単独報告) は許容する。
`single-assign` の `--fix`: 上記 3 条件は「その name = expr を name := expr へ置換しても意味が保たれる」ことの証明でもある (auto-vivify なので新規 immutable 宣言になり、書き込み 1 回ゆえ再代入も無い)。よって --fix は対象の bare = を := へ機械的に書き換える (型注釈 x: T = v は x: T := v を保つ)。これは「検出=修正可能」を保つ削除系でない唯一の束縛ルールで、削除判断を要する unused / self-assign が --fix 対象外なのと対照的。
出力と終了コード
検出は <file>:<line>:<col>: <msg> (<rule>) 形式で stdout に出す。
| 状況 | code |
|---|---|
| 検出なし | 0 |
| 検出あり / 構文エラー | 1 |
| I/O エラー / 引数不正 | 2 |
構文エラーのあるファイルは lint せず、診断を stderr に出して exit code 1 に寄与する。
--fix 指定時は診断を出力せず、修正対象を書き換えて (並置化 + 条件分岐の簡素化 + import 整列 + 単一代入の := 化) ファイルを上書きする。修正成功 (変更なしを含む) は 0、構文エラーのあるファイルは整形せずスキップして 1、I/O エラーは 2。
検査設定 (hikari.hika)
lint の設定は全サブコマンド共通の設定ファイル hikari.hika で行う (スキーマ・探索・実効 skip 解決は format.md「設定ファイル」)。lint が見るのは検査除外 `skip` (真偽値) で、共通 skip かツール別 lint := [skip := true] で指定する。
# tools/jsgen/hikari.hika — デモは lint/check/inspect から外すが fmt は保つ #[hikari] skip := true fmt := [skip := false]
- 効果: 実効 skip が真になるファイルは lint が検査せず、指摘も
--fixの書き換えも行わない。検査対象ツリーに含めたまま、特定のサブツリー (デモ・生成物・非正規形の見本など) だけ検査対象から外したいときに使う。 - 既定: 指定が無ければ「除外しない」。
widthなど lint が見ないスロットは無視する (前方互換)。 - 不正な設定 (構文エラー /
skipが非真偽値) は引数不正扱い (exit code2) とし、診断を stderr に出す。 - fmt との関係: 同じ
hikari.hikaの共通skipで fmt と lint をまとめて外せる。fmt だけ残すならfmt := [skip := false]で上書きする。--fixの整形幅widthも同じhikari.hikaから読む。