本文へ移動
Hikari 仕様

スタイル検査 (hikari lint) 仕様

本書は hikari lint の仕様を定める。起動ディスパッチ全体は hikari-command.md、言語の意味論は language-spec.md を参照。

パースは通るが書き方として問題のあるパターンを検出する。構文エラー (= 静的検査 hikari check (check.md) の領域) とは別レイヤーで、スタイル/品質に絞った保守的な (誤検出を避ける) 検査を行う。check / format / lint の住み分けは hikari-command.md §1.2

構文

hikari lint [--fix] [path...]

path はファイルまたはディレクトリ (ディレクトリは配下を再帰して .hika を収集)。引数省略時は cwd を対象とする。収集ファイルはパス昇順で処理する。

--fix自動修正可能なルールを修正してファイルを上書きする (対象は control-juxtaposition / unit-else / nested-if / trivial-match / bool-match / import-order / single-assign)。抑制指示 の付いたサイトは書き換えない。修正の際ソースは整形され (hikari format 相当 + 制御構造の並置化 + 条件分岐の簡素化 + import のグループ整列 + 単一代入の := 化)、内容が変わる場合のみ書き込む。--fix 指定時は診断を出力しない。

ルール

ルール 検出内容
unused 値が一度も読み出されないローカル束縛。immutable 宣言 name :=、mutable 宣言 mutable name :=、分割束縛 a, b := / { a, b } := (mutable 前置を含む) の各名が対象。代入先 (書き込み) は使用に数えないため 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 mutable name := expr で宣言した mutable local (language-spec.md §6.1) が、宣言のほかに一度も代入されず、かつ最低 1 回読み出される。実質 immutable なので mutable を落として name := expr と書ける。判定は宣言サイトから始め、その束縛への書き込みサイト (= / 複合代入 / 分解 =) を部分木全体で数える (下記)。発火条件が置換の意味保存を保証するので --fixmutable を落とす
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.9when はまさに if(cond, then, { () }) の糖衣) なので when を使う (--fix で自動変換)
nested-if else ブロックの唯一の文がさらに完全適用の if / when で、述語分岐が 2 本以上連なっている。Hikari の if は else-if を持たず右ネストするため、平坦な述語多分岐は subjectless match の述語連鎖 (prelude.md §8.7) で書く (--fix で自動変換。末尾の else が { () } なら catch-all 無し、通常ブロックなら _ => になる。when 終端の連鎖は対象外 — 下記「判定範囲」)
trivial-match subjectless match のアームが「述語 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 アームが then、false アームが else に対応)
redundant-param-type 関数の引数型が束縛注釈とスロットの 2 箇所に書かれている。束縛注釈が関数型 name: {P… | R} := {…} で右辺が body 付きオブジェクトリテラルのとき、全スロットが型注釈を持ち、各 Pᵢ とスロットの型式が構文的に同一なら報告する。スロットは引数の名前と型の両方を持つため注釈のパラメーターリストより情報量が多く、注釈側が丸ごと復元可能な重複になる。値側に寄せる (束縛注釈を落とし、結果型は body 末尾アスクリプションへ移す) のが推奨 (--fix は無し。下記)
shadow body 領域の宣言 (name := / 分解 a, b := / { a, b } := / inner-name) が、外側スコープの束縛または prelude 名と同名で、それを隠している。同一スコープの重複は宣言エラー (language-spec.md §6.1) なので本ルールの対象は別スコープの隠蔽だけ。スロット宣言 (パラメーター) と match のアームパターン束縛は対象外 (下記)。--fix は無し
import-order top-level import 束縛 (body 領域・スロットリスト領域とも) が std (std:) → 外部パッケージ (pkg:) → 相対パス の順に整列していない。群内はパス文字列の辞書順、群境界は空行 1 つで区切る (--fix で自動整列)。並べ替えは抽象構文を変えるため hikari format ではなく --fix が担う (format は import をソース順のまま先頭へ hoist する)。ネストしたオブジェクト内部の import は対象外
block-arity 高階メソッドへ渡したブロックの未束縛スロット数が、その位置で渡される値の数と合わない。xs.each { v, i | … }each が値を 1 個渡すのでスロット 2 つのブロックが部分適用になり (language-spec.md §3.6)、その値が捨てられて本体が 1 度も走らない。意味論としては正しいので実行時にも診断が出ず、他言語の each の綴り (要素と添字を受ける形) をそのまま持ち込むと黙って何も起きない。渡される値の数は受け手の静的型が決める (下記) ので、型が分からない位置では報告しない。--fix は無し (スロットを足すか減らすかは書き手の意図で決まる)
default-slot-order 既定値を持つスロットが、未束縛のまま残したいスロットよりに宣言されている。未束縛スロットは既定値の有無に依らず宣言順に実引数で埋まるため (language-spec.md §3.5)、判別子タグのように呼び出しで埋めたくないスロットを前に置くと実引数に乗っ取られる ({ t := "binop", op, l, r |}("+", 1)t から埋まる)。必須スロットを先に並べる。ファイルルートの slot-list と match のアームパターンは対象外 (下記)。1 リテラルにつき最初の 1 件だけ報告する (--fix は無し — 並べ替えは呼び出しの引数順を変えるため書き手が判断する)
explicit-inner-bound-body body が inner_bound 名への裸の参照 1 つだけで、body を省略した綴りと同じ値になる (language-spec.md §2.2)。{ x, y |} と書けば inner_bound 宣言ごと落とせる。--fix は無し (宣言の削除を伴うため)
unused-inner-bound body・既定値のいずれからも一度も参照されない inner_bound 宣言 (language-spec.md §4.2.1)。既定値の式は呼び出しのたびに評価されるため、既定値が作る関数リテラルの本体から捕獲した束縛を読むこともできる — その参照も数える。判定根拠は unused の inner-name と同じで、inner_bound はこのリテラル 1 つの中だけで完結する自己参照であり、リテラルの外から名前で参照する経路が無い。型注釈を持たない (§1.4) ので、inner のような「型を表明する用途」の例外は無い
shared-mutable-default 名前で束ねた構築子の、既定値が可変な値であるスロット。既定値はリテラル評価時に 1 度だけ確定するため (language-spec.md §12.1)、同じ構築子から 2 回派生させると両方が同じものを掴む。判定は構文のみで行い、既定値の式が可変性を綴りに持つ形 — 可変スロット (mutable) を持つ body 省略リテラル — のときだけ発火する。派生ごとに新しい値が要るなら実引数で渡すか、body 付きの factory で毎回リテラルを評価する。--fix は無し (意図の判別が要る)
non-associative-parallel-fold std:parallelfold (std/parallel.md) へ渡した fn が、ちょうど 2 つの未束縛スロットを持ち、本体が - / / / % のいずれかの二項適用ちょうど 1 つで、その左が 1 つ目のスロット・右が 2 つ目のスロットへの裸の参照である ({ a, b | a - b } は当たり、{ a, b | b - a } / { a, b | a - 0 } / { a, b | (a - b) - 1 } は当たらない)。並列 fold はチャンク結果をまとめ直すので fn が結合律を満たさないと逐次 fold と違う値になるが、結合律は静的に検査できない。綴りだけで非結合と分かる形をここで捕まえる。判定は綴りのみで、std:parallel を import しているファイルの、その import が束ねた呼び出しに限る — 分解 import (import { fold } := "std:parallel") の裸の fold 呼び出しと、名前空間ごと束ねた import (import parallel := "std:parallel") の parallel.fold(...) のどちらも対象である (fold は素の関数呼び出しでありメソッドではないので、引数の静的型は引かない)。--fix は無し (逐次 fold にするか結合律を満たす fn へ直すかは書き手が決める)
slotlist-destructure タプル分解 a, b := expr の綴りが slot-list 領域に置かれている。タプル分解は文の位置でのみ使えるので (language-spec.md §6.3)、内側リテラルの slot-list ではスロット宣言 —「必須スロット a + 既定値 expr を持つスロット b」— として読まれる。意味論としては正しく実行時エラーも出ないため、block-arity と同じく黙って別の値が通る。綴りだけでは決まらない ({ x, y := 0 | … } は省略可能引数の正しい綴り) ので、既定値の静的型がタプルであり、その要素数が「直前に連なる無注釈の bare スロット数 + 1」と一致する形だけを報告する (下記)。対象は入れ子リテラルの slot-list だけである — ファイルトップレベルでは区切りが body 側の規則に従うので a, b := expr は正しいタプル分解であり、エラーではない (language-spec.md §13.1)。--fix は無し (body へ移すか本当にスロット宣言なのかは書き手が決める)
implicit-export-all ファイルに export が無く、かつファイル内から一度も参照されないトップレベル束縛があるexport が無いと全トップレベル束縛が公開扱いになるため (language-spec.md §13.2)、unused はそれらを未使用と判定できない (下記「export 時の非公開トップレベル束縛」)。つまりこの状態では、ファイル内で死んでいる束縛と外部 API が見分けられないexport { … } で公開する名前を挙げれば残りは私的と確定し、unused がそこまで働く。判定はファイル内で閉じる (hikari lint は import を解決しない。hikari-command.md §1.2) ので、本当に全部が API なら指示 (下記) で理由を添えて抑える。--fix は無し (どれを公開するかは書き手が決める)
obsolete-file-header ファイル先頭に #{ hikari } / #{ hikari |} の 1 行が残っている。かつてこの行がファイルの読み方 (宣言の領域か文の領域か) を選んでいたが、ファイルは 1 つの slot-list 領域になり選ぶものが無くなったため (language-spec.md §13.1)、今はただの行コメントとして黙って残る。意味を持たない定型が全ファイルの先頭に居座るので落とす。判定は「最初の非空白・非コメント行より前にある、括弧の内側が hikari の 1 語 (と末尾 |) だけの #{…} 行」で、文字列・三連引用符の中は対象外 (--fix で行ごと削除する)
invalid-allow 抑制指示 (下記) が不正。理由が無い、またはルール名が既知のルール一覧に無い (誤記)。どちらも「書いたつもりの抑制が効いていない」ことを意味するので、黙って素通りさせない
unused-allow 抑制指示がどの診断とも対応しなかった。ルールの変更や対象コードの修正で不要になった指示を掃除する

重大度とタグ

hikari lint は評価・ビルドを行わず書き方を指摘するだけであり、重大度の規則 (「Error はそのモードでプログラムの評価・ビルドを止める診断」。static-analysis.md「診断コードと重大度」) の対象外なので、重大度は Warning 以下とする。lint 内部の区別は「動作が意図と違いうるか」で行う。

  • Warning — 潜在的な不具合。self-assign は効果の無い代入、non-tail-recur は深い反復でスタックを消費しうる。unused は到達不能な記述を残している。
  • Information — 意味は正しく、書き方の推奨にとどまるもの。

消せる記述 (unused / redundant-param-type / unused-allow) には「不要」タグを付ける。エディターはこのタグが付いた範囲を淡色表示するため、未使用の束縛が一目で分かる。

code severity tags
unused Warning 不要(LSP DiagnosticTag: Unnecessary)
self-assign Warning
non-tail-recur Warning
invalid-allow Warning
redundant-param-type Information 不要(LSP DiagnosticTag: Unnecessary)
unused-allow Information 不要(LSP DiagnosticTag: Unnecessary)
implicit-export-all Information
obsolete-file-header Information 不要(LSP DiagnosticTag: Unnecessary)
single-assign Information
shadow Information
control-juxtaposition Information
unit-else Information
nested-if Information
trivial-match Information
bool-match Information
import-order Information
block-arity Warning
default-slot-order Warning
unused-inner-bound Warning 不要(LSP DiagnosticTag: Unnecessary)
explicit-inner-bound-body Information 不要(LSP DiagnosticTag: Unnecessary)
shared-mutable-default Information
non-associative-parallel-fold Warning
slotlist-destructure Warning

検出は保守的で、検出漏れ (false negative) は許容し誤検出 (false positive) を避ける。unused はシャドウイング等の曖昧なケースでは報告しない。

block-arity に渡される値の数: 綴りだけでは決まらない。同じ each でも List は要素 1 個を渡し、std:map(キー, 値) の 2 個を渡す。さらに要素がタプルなら、1 個の値が要素適用でスロットへ展開される (language-spec.md §3.5 の注 — 「R=1 は丸ごと、R≥2 は要素タプルを destructure」) ので、List((Int, String))each はスロット 1 個でも 2 個でも正しい。したがって本ルールだけは受け手の静的型を要する。

  • 対象は prelude が定めるSequence のメソッドのうち、受け手の種別に依らずブロックへ渡す個数が揃う綴りだけである。揃わない綴り (or_else / unwrap_or_elseOption は 0 個・Result は 1 個) は対象外。
  • 要素適用が効くのは値が 1 個の位置だけである。2 個以上を渡す綴り (fold / sort_with) では許される数が 1 つに定まる。
  • 受け手の静的型が確定しない位置、およびSequence のメソッドを持たない型ラベル (std:map の不透明型ほか) は報告しない — 同じ綴りで別の個数を渡すので、綴りだけで断じてはならない。

slotlist-destructure の判定範囲: 同じ綴り a, b := expr が、body 領域では位置分解・slot-list 領域ではスロット宣言になる (language-spec.md §6.3)。どちらの意図かは綴りに現れない{ x, y := 0 | … } は「必須 x + 省略可能 y」という正しい関数の綴りである。区別できるのは既定値のだけなので、次を全部満たす形に限って報告する。

  • 対象のリテラルに、型注釈も既定値も持たない bare スロットが n 個 (n ≥ 1) 連なり、その直後に型注釈を持たない name := expr が続く (綴りの上で a, b := expr と一致する並び)。型注釈の付いたスロットは「これはパラメーターである」と書き手が明示したマークなので、連なりを断ち切る。
  • n は接尾で数える — 本物のパラメーターが前に並ぶ形 ({ p, a, b := pair! \| … }p) を落とさないため。要素数が n + 1 なら n は一意に決まる。
  • 既定値 expr静的型がタプルで、その要素数が n + 1 と一致する。省略可能引数として書かれた既定値 (0 / "" / リテラルのレコード) はここで落ちる。
  • 型が確定しない位置では報告しないblock-arity と同じく、答えを渡されなければ黙る。

型を見る必要がある規則はこの 2 つ (block-arityslotlist-destructure) だけで、lint 自身は型検査器に依存しない — 問いの形だけを持ち、答えは組み立てる側が渡す (内部構造は ../internals/architecture.md)。

redundant-param-type の判定範囲: 判定は構文のみで行う (本ルールは型解決を使わない)。次は対象外とする (検出漏れ側に倒す):

  • 一部のパラメーターだけが一致する形 — 書き手が意図的に一部だけ絞っている可能性と区別できず、削除提案も単純にならない。全一致のときだけ注釈のパラメーターリストが丸ごと復元可能と言える。
  • 型別名経由の一致 (type Id := String のもとで片方が Id・片方が String) — 別名は展開せず不一致として扱う。同じかどうかは静的型検査が見る (static-analysis.md §2。照合は別名を無視するので IdString は同一型=矛盾ではない) のに対し、本ルールが見るのは同じ綴りかどうかで、役割が重ならない。綴りが完全に同じときだけ発火するので、どちらを消しても失われる情報が無いことが構造的に保証される (別名を展開して報告すると、Id という読み手向けの名前を捨てる修正を勧めることになる)。
  • 無注釈スロットを含む形 (add: {Int, Int | Int} := {x, y | x + y}) — 型はスロットに無いので重複していない。
  • arity 不一致 — shape が確定しない。

宣言が食い違う場合 (重複でなく矛盾) は lint ではなく静的型検査が報告する (static-analysis.md §2.6「inner-name 型と slot/結果型の整合」/「関数型注釈と実体シグネチャの整合」)。型解決を要するため層が分かれる。2 層の役割は「同じか」を静的型検査が・「同じ綴りか」を lint が見る、と分けられる。

--fix は提供しない。値側への寄せは「束縛注釈を消し、body 末尾式に : R を付ける」変換だが、末尾式が when / match / 多行のブロック引数呼び出しで終わる場合の安全な書き換えが自明でないため、指摘に留める。

条件分岐系ルール (unit-else / nested-if / trivial-match) の判定範囲: 判定は構文のみで行い、control-juxtaposition と同じくスコープ解決・シャドウイングは見ない。誤検出を避けるため次は対象外とする (検出漏れ側に倒す):

  • 分岐がスロット付き・inner-name 付き・conduit / loop などリテラルの素のブロック ({ 文… }) でないもの (nested-if のアーム化・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 の連鎖収集はアーム化できないリンク (分岐が素のブロックでない等) で打ち切り、そこまでを _ => (catch-all) にまとめる。収集できた述語が 2 本未満なら報告しない。
  • when 終端の連鎖 (if a { A } { when b { B } }・段数を問わない) — 末尾の when は構造的に Any で (static-analysis.md §2.9 が分岐の収束要求から外している)、外側の if が他の枝と比べる相手をそこで覆っている。match へまとめると覆いが外れて B がアーム body として剥き出しになり、A と直に比べられるため、通っていた綴りが divergent-result で止まりうる。型を引けない位置なのでまとめない側へ倒す。覆いが外れるのは末尾の when だけで、連鎖の途中の body に置いた when (if a { when x { A } } { … }) はアーム body の中に残るため対象である。while 終端は連鎖のリンクにならず catch-all の body へ落ちるので、同じく Any のまま保たれる。

non-tail-recurloop に限る理由: 非末尾の自己再帰は一般には正常であり (fact / fib・木の再帰・分割統治・アキュムレーターなしのリスト構築)、それらに警告するのは誤検出になる。loop 注釈だけが「この関数はループ=定数スタックで回るべき」という意図を静的に宣言するため、その反復ドライバーの非末尾 self 呼び出しに限れば高信号・低誤検出で報告できる (language-spec.md §18.5 の末尾位置判定を再利用する)。loop を使わない一般の再帰は対象外である — 意図の signal がある箇所だけを見る。

unused がオブジェクトのスロットを対象としない理由: スロット (関数引数を含む) は、束縛元のオブジェクトが関数として呼ばれて body 内で参照されるだけでなく、obj.slot で外部から参照され得る。そして Hikari は型情報を静的に持たないため、ある .slot 参照がどのオブジェクトのどのスロットを指すかを hikari lint は静的に特定できない。とりわけ次のパターンでは、スロットの参照がそのファイルの外 (別ファイル) に現れる:

  • ファクトリ関数が構築して return するオブジェクト のメソッド/フィールド (importer が account.deposit 等で参照)
  • 引数として他の関数に渡すレコード (呼び出し先が .field を読む)
  • type / enum で宣言される 型のフィールド名

これらはファイル内に .slot が現れないため、スロットを未使用検出の対象に含めると誤検出になる。name := / name = / 分割束縛といった ローカル束縛は名前で外部参照されない ので安全に判定できるが、スロットは外部 API になり得る点が本質的に異なる。この区別はエスケープ解析や型情報なしには厳密化できないため、unused はスロットを一律に対象外とする (検出漏れを許容し誤検出を避ける保守的方針)。

export 時の非公開トップレベル束縛 (例外): ファイルに export { … } があるとき、上記の一律対象外には例外がある。export のリストに載っていないトップレベル束縛 (Root 直下の値スロット) は、language-spec.md §13.2 (export によるファイル外公開の限定) により importer から到達不能 と保証される (丸ごと束縛越しの .slot・分解 import とも該当スロット無し)。つまりこれらの束縛は外部 API ではなくファイル私的であり、ローカル束縛や inner-name と同じ理屈で安全に未使用判定できる。ファイル内で一度も参照 (値読み出し) されない非公開トップレベル束縛は unused として報告する。

この例外は次には及ばない (対象外・検出漏れ側に倒す):

  • export が無いファイル (全トップレベル member が公開されるため、非公開の区別が付かない。この状態自体は implicit-export-all が指摘する)。
  • Root 以外 (ネスト) のオブジェクト が持つスロット (そのオブジェクト自身がファイル外へ渡り得るため到達可能性を静的に確定できない。上記の一律対象外の理由がそのまま当てはまる)。
  • export のリストに載っている公開スロット (from importer 到達可能)。

single-assign が数えるもの: 宣言サイトは構文で分かる (mutable が書いてある位置がそれである。language-spec.md §6.1) ので、本ルールが解くのは「その束縛がその後書かれるか」だけである。判定はオブジェクトリテラル (slot-list + body が 1 フレーム、language-spec.md §1.3) をノードとするスコープ木の上で行い、次を満たす宣言を報告する:

  • その束縛の部分木全体での書き込み (= / 複合代入 += / 分解 =) が0 回。部分木を辿る際、同名を再宣言してシャドウする内側スコープ (別束縛) には入らない。
  • その束縛が最低 1 回読み出される (代入先は読み出しに数えない)。write-only は unused の領分なので二重報告しない。

書き込みの集計に部分木が要るのは、内側のリテラルが外側の可変を更新できるためである (mutable sum := 0 ののち xs.each { x | sum = sum + x })。この形は書き込み 1 回として正しく除外される (誤検出回避の要)。逆に、内側スコープが同名を自前で :=/スロット宣言して書くケースはシャドウ境界で切られ、外側の判定に干渉しない。

single-assign--fix: 上記 2 条件は「その宣言から mutable を落としても意味が保たれる」ことの証明でもある (宣言サイトはそのままで、書き込みが無いので再代入も無い)。よって --fixmutable の 1 語を落とす (型注釈 mutable x: T := vx: T := v になる)。これは「検出=修正可能」を保つ削除系でない唯一の束縛ルールで、削除判断を要する unused / self-assign--fix 対象外なのと対照的。

default-slot-order の判定範囲

次の 2 つは対象外である。どちらも「宣言順に実引数で埋まる」規則が働かない位置で、報告すると誤検出になる。

  • ファイルルートの slot-list — トップレベル束縛はその場で確定する宣言であって、呼び出しで埋まるスロットではない (language-spec.md §13.1)。並びに意味が無い。
  • match のアームパターン — レコードパターンの kind := "text" は既定値ではなく照合する値である (prelude.md §8.4)。並べ替えても照合の意味は変わらず、実引数に乗っ取られる形も無い。パターンだけを飛ばし、ガードとアーム body には降りる。

型・enum スロット (§17.4) は実行時の値を持たないので、既定値スロットとしても必須スロットとしても数えない。

shared-mutable-default の判定範囲

共有が観測できるのは同じ構築子を 2 回以上派生させたときだけである。したがって見るのは、名前に束ねて何度でも派生させられる構築子に限る。

  • ローカル束縛の右辺 (c := { … |} / c = { … |})
  • スロットの既定値 ({ c := { … |} |}) — こちらも名前 (o.c) で引いて何度でも kick できる

その場で kick する形 ({ … |}!) は派生が 1 回しか起きないので対象外である。既定値を分け合う相手がいない。実引数やリストの要素として渡すだけの形も、名前で引き直して 2 度目を kick する経路が綴りから見えないため報告しない。

「その値が可変か」の判定も綴りだけで行う (lint は型解決を持たない)。検出漏れ側へ倒し、次は対象外とする。

  • 名前経由 (xs := { mutable n := 0 |}! の後 { ys := xs |}) — 別名の連鎖と再代入まで追う必要があり、この層では健全に決められない。
  • body を持つリテラル ({ f := { { mutable n := 0 |} } |}) — 関数なので呼び出しのたびに新しい値を組み、共有は起きない。

条件分岐の 4 規則は制御名が取り直されていたら報告しない

unit-else / nested-if / trivial-match / bool-match は推奨形を if / when の綴りで
読み書きする。制御構造は予約語ではなく prelude が束縛した値なので
(language-spec.md §1.2.2)、書き手が同じ綴りを
取り直していれば、その綴りはもう別の関数を指す。綴りだけで推奨形へ書き換えると --fix
評価結果を変える

when := { c: Bool, t: Any | println "user-when" }
f := {| if true { println "then" } { () }; 1 }
println f()        # 書き換え前は then、書き換え後は user-when

したがって、その位置から見える語彙スコープの鎖 (自分のフレームと祖先のフレーム) のどこかで
if / when が宣言されていたら、その名前を読み書きする規則は報告も書き換えもしない。
見るのは規則が実際に読み書きする名前だけである — when だけを取り直したファイルでも、
if しか要らない bool-match は働く。

shadow の判定範囲

本ルールはオブジェクトリテラル (slot-list + body が 1 フレーム、language-spec.md §1.3) と
match のアーム body をノードとするスコープ木を組み、外側スコープの束縛および prelude 名と同名の宣言を報告する。
single-assign のスコープ木を流用しないのは、あれがアーム body を親フレームにまとめているためで、
本ルールで同じ扱いをすると兄弟アームの同名宣言を隠蔽と誤認する。

報告する宣言は次の 3 つ。いずれも書き手が名前を自由に選べる位置である。

  • body 領域の name := expr / mutable name := expr
  • body 領域の分解 := (a, b := expr / { a, b } := exprmutable 前置を含む)
  • inner-name ({inner self | …} の self)

次は報告しない。

  • スロット宣言 (パラメーター) とスロット領域の分解 — 名前が呼び出し規約から決まる位置で、書き手の自由選択ではない。
    束縛は登録するので、内側スコープから見れば隠される側にはなる。
  • match のアームパターン束縛 — 名前が subject の構造から決まる位置。理由はスロットと同じ。
  • = による代入= はスコープチェインを遡って既存束縛を更新するだけで、名前を導入しない
    (language-spec.md §6.1)。よって原理的に隠蔽を作れず、対象外ではなく該当し得ない。
  • 破棄子 _ — 環境に入らない。
  • f := { inner f | … } — 束縛名と同じ inner-name は自己再帰の慣用形であって隠蔽ではない。
    body 領域のローカル宣言・スロット宣言のいずれでも、また mutable の有無に依らず同じに扱う
    (束縛の置き場所でも可変性でも慣用形の意味は変わらない)。

--fix は用意しない。改名は意味を保証できず、どの名前にすべきかも機械には決められない。

prelude 名を対象に含める: prelude 名も普通の束縛であり言語は再束縛を妨げない
(language-spec.md §1.2.1 / §17.2)。本ルールはそれを禁止するのではなく、
隠したことを書き手に一言伝える。したがって type Error := {code: Int |} のような意図的な定義でも鳴る。
意図が明確なら抑制指示 (# hikari:allow shadow — 理由) で消す。

prelude には error / input / range / min / max / all / now のように日常語と衝突する名前があり、
これらはローカル名としても自然なので、実際に鳴る頻度は他の隠蔽より高くなる。重大度を Information に
とどめているのはこのためで、隠すこと自体を否定はしない。意図した隠蔽なら抑制指示で消す。

prelude 自身のソースのトップレベルは対象外: 処理系が同梱する prelude のソース
(stdsrc/prelude.hikastdsrc/preludetypes.hika) では、トップレベルの宣言が prelude 束縛を
隠しているのではなく作っている。そこだけは prelude 名との一致を報告しない。判定は処理系が
取り込んだ綴りとの一致で行うので、ソースを直せば判定もそのまま追従する。入れ子の位置で同じ名前を
宣言すればファイルルートの宣言を隠すことになり、そちらは「外側の束縛」として報告される。

抑制指示 allow

lint は「推奨形へ書き換えても意味は変わらない」ことを前提に報告する。この前提が成り立たないコードがある — 代表は bench/ のコーパスで、目的が「特定の構文の実行コストを測る」ことなので、構文を変えると計測対象そのものが変わる (bench/README.md)。そうしたサイトはコードを曲げず、指示で個別に抑制する。

構文と適用範囲

# hikari:allow <rule> — <reason>
<target line>
  • 指示になるのは自分の行だけを占める行コメント。コードの後ろに続く行末コメントは指示として扱わない (複数行にまたがる構造でどの行を指すか曖昧になるため)。
  • コメント本文 (# を除いた内容) を前後の空白を除いて読み、hikari:allow に続けて空白がある形だけを指示とする。
  • かかる先は下へ辿って最初に現れたコードのある行。途中の行コメント (指示・通常のコメントとも) は読み飛ばす — 理由が必須である以上、指示の後に説明を続ける書き方は普通に起きるため。そこに報告された <rule> の診断を 1 件黙らせる。
  • 空行で打ち切る。指示と対象の間に空行があれば対象は無いものとし、unused-allow になる。指示は対象に隣接した注記であって、離れた場所へ効く宣言ではない。
  • 同じコメント塊にある指示はすべて同じ対象行にかかる (複数ルールの重ね掛け)。
  • ルール名の後に、空白を除いて非空の残りが要る (理由)。区切り文字は決めない — 理由は人間向けの自由文であり、機械が読むのはルール名までである。理由は次行以降のコメントへ続けてよい。
# hikari:allow unit-else — when は self-host された conduit で組込 if の約 2 倍。
# ここを書き換えると classify ではなく when を測ることになる
if (classify k == "pos") {
  hits = hits + 1
} {
  ()
}

指示自身を検査する

指示は書いた時点で正しくても、ルールや対象コードが変われば意味を失う。黙って腐らないよう、指示自身を invalid-allow (理由が無い / ルール名が既知のルールに無い) と unused-allow (どの診断とも対応しなかった) が検査する。この 2 ルールは allow で抑制できない (循環するため)。

--fix との関係

--fix は抑制されたサイトを書き換えない。指示は「この形のままにする」という表明なので、自動修正が上書きすると表明だけが取り残される (対象を失った指示は次の実行で unused-allow になる)。

理由を必須にし、未使用を報告する理由

抑制の価値は「なぜここは例外なのか」が後から追えることにある。理由の強制も未使用の検出も、他の検査系では追加規則やオプションとして opt-in で持たれるのが通例である。Hikari は両方を既定にする — 既定は後から締める方が移行コストが高く、また「抑制したつもりで効いていない」「もう要らない抑制が残っている」はどちらも黙って通ると気づけない種類のエラーだからである。

インライン指示を採り、他の形を採らない理由

  • #[allow(<rule>)] 属性形は採らない。# で始まる行はすべて行コメントである (language-spec.md §1.1) ため、属性形を導入するには字句層に「コメントに見えて意味を持つ行」を作ることになる。行コメント形なら字句・構文・整形のいずれにも手を入れずに済む (hikari format は行コメントを既に保存する)。
  • 設定ファイルのルール別 allow は採らない。ディレクトリ単位なのでサイト固有の根拠を書けない。抑制が要るサイトは「なぜこの形でなければならないか」が個別に違い、まとめて許可すると根拠が失われる。加えて、以後そのディレクトリに入る本当に直すべき違反も黙る
  • ファイル単位の skip (下記) とは役割が違う。skip は「このツリーは lint の対象外」という宣言であってルール単位の例外ではなく、生成物・非正規形の見本のように検査自体が意味を持たない領域に使う。個々のルールだけを外したいときは指示を使う。

将来枠

次は現時点で持たない。制約として読者が知る必要があるので、ここに明記する。

  • 行末コメント形の指示 (if c { t } { () } # hikari:allow unit-else — 理由)。複数行にまたがる構造でどの行を指すか曖昧になるため対象にしない。1 行で収まる形が増えて需要が出たら、かかる先を「そのコメント自身の行」と定めたうえで足せる。
  • ディレクトリ単位の allow (hikari.toml 側でルール別に許可する形)。個別指示で足りなくなったら検討する。
  • hikari check (静的検査) の抑制。抑制は lint にしか無く、静的検査の診断は黙らせられない。検査自体を外したいときはファイル単位の skip を使う。

出力と終了コード

検出は <file>:<line>:<col>: <msg> (<rule>) 形式で stdout に出す。

状況 code
検出なし 0
検出あり / 構文エラー 1
I/O エラー / 引数不正 2

構文エラーのあるファイルは lint せず、診断を stderr に出して exit code 1 に寄与する。

--fix 指定時は診断を出力せず、修正対象を書き換えて (並置化 + 条件分岐の簡素化 + import 整列 + 単一代入の := 化) ファイルを上書きする。修正成功 (変更なしを含む) は 0、構文エラーのあるファイルは整形せずスキップして 1、I/O エラーは 2

検査設定

lint の設定は全サブコマンド共通の設定ファイル hikari.toml で行う (スキーマ・探索・実効 skip 解決は format.md「設定ファイル」)。lint が見るのは検査除外 skip (真偽値) で、共通 skip かツール別 [lint]skip = true で指定する。

# tools/vscode-hikari/testdata/hikari.toml — 見本は lint/check/inspect から外すが format は保つ
skip = true

[format]
skip = false
  • 効果: 実効 skip が真になるファイルは lint が検査せず、指摘も --fix の書き換えも行わない。検査対象ツリーに含めたまま、特定のサブツリー (デモ・生成物・非正規形の見本など) だけ検査対象から外したいときに使う。
  • 既定: 指定が無ければ「除外しない」。width など lint が見ないキーは無視する (前方互換)。
  • 不正な設定 (TOML の構文エラー / skip が非真偽値) は引数不正扱い (exit code 2) とし、診断を stderr に出す。
  • format との関係: 同じ hikari.toml の共通 skip で format と lint をまとめて外せる。format だけ残すなら [format]skip = false で上書きする。--fix の整形幅 width も同じ hikari.toml から読む。
  • 抑制指示 との関係: skip は「このツリーは検査自体が意味を持たない」という宣言で、粒度はファイル/ディレクトリ単位・ルール横断である。個々のルールだけを、理由を添えて外したいときは抑制指示を使う。skip されたファイルは lint が走らないため、その中の抑制指示も読まれない (unused-allow も出ない)。