自作の小さな Node のサーバで、リポジトリの作業ツリーにある app/downloads/ のファイルをそのまま配っています。
配っているファイルを置いたり直したりしたら、そのたびに HTTP で取り直して md5 やバイト数を比べ、「配っているものを確かめた」と記録していました。
配布物のうち unattended-check.mjs を置いた・変えたコミットは、いまのところ全部で14回あります(最後は 2026-09-11)。
確かめる順番を直した 09-06 より前の8回について、記録した数は8回のうち7回、git の中身(LF)の数でした。その後に配り続けていたはずの CRLF のバイト列の数ではありません。
書いた直後、main へ戻る前に取ったので(取った数は、その作業ブランチのコミットの記録に入っています)、取った瞬間はその LF の版が実際に配られていたはずです。ところがそのあとブランチを切り替えた時点で、作業ツリーのファイルは別のバイト列に書き換わります(新しく置いた回では、いったん消えます)。
(「はず」と書くのは、09-06 より前の8回のうち、その後に配っていたバイト列を取り直してあるのが1回だけだからです。後ろで数を出します)
順番を直したあとの6回のうち、記録のある4回は、4回とも CRLF の側の数でした。
原因は git merge ではありません。
測ると、merge を1回も呼ばなくても、git switch で往復するだけで同じことが起きます。
書き換えているのは、そのファイルを作業ツリーへ書き出す checkout のほうです。
この記事で書けるのは、次の範囲です。
- Windows 11 / Git for Windows 2.55.0 / Node v24.18.1 の1台で測ったこと
-
core.autocrlf=trueで.gitattributesを持たないリポジトリで、下の手順を踏んだときのこと - 読者側の設定で結果が変わる条件は、見つけたものだけ打ち消しています(後ろの「おまけ」と再現スクリプトの冒頭)。見つけていない条件があれば、同じ結果にならないことがあります
実測1: 私が実際に踏んだ形で再現した
再現スクリプトの全文は後ろに載せています。測ったのは3つです。
main に既にある tool.mjs(LF・7バイト)を、作業ブランチで3行の別の中身(LF・48バイト)に書き換えます。
(a) 既にあるファイルを書き換えて、main に戻って merge する
| 時点 | バイト | CR | LF | 中身 |
|---|---|---|---|---|
| 作業ブランチで書いて commit した直後 | 48 | 0 | 3 | 新しいほう |
git switch main の直後(merge の前) |
8 | 1 | 1 | main の古いほう(CRLF) |
| merge の後 | 51 | 3 | 3 | 新しいほう(CRLF) |
git の中身(git cat-file blob HEAD:tool.mjs) |
48 | 0 | 3 | 新しいほう(LF) |
merge を待つまでもありません。git switch main をした時点で、作業ツリーのファイルはもう別のバイト列です。
しかもその時点で置かれているのは main の古い中身で、CRLF になっています。
(b) merge を1回も呼ばない。git switch で往復するだけ
| 時点 | バイト | CR | LF |
|---|---|---|---|
| 作業ブランチで書いて commit した直後 | 48 | 0 | 3 |
git switch main → git switch work の後 |
51 | 3 | 3 |
**merge は1回も走っていません。**それでも作業ツリーのファイルは CRLF に書き換わります。
(c) .gitattributes のパターンを1行だけ絞る
app/downloads/*.mjs text eol=lf の1行を置いて、(b) と同じ往復をします。
| ファイル | commit 直後 | 往復の後 | git check-attr text |
|---|---|---|---|
app/downloads/tool.mjs(対象) |
48・CR 0 | 48・CR 0 | set |
other.mjs(対象外) |
48・CR 0 | 51・CR 3 | unspecified |
パターンに当てはまるファイルだけが LF のまま残り、当てはまらないファイルはこれまでどおり CRLF になります。
なお (c) を測っていて、自分の測り方の誤りを1つ見つけました。
最初は対象外のファイルを枝の間で変更していなかったので、git がそのファイルを作業ツリーへ書き出さず、改行も変換されませんでした(48・CR 0 のまま)。
対象外のファイルも「書き換えて往復する」形にして測り直したのが上の表です。
**書き出されないファイルは、何も起きません。**これも「書き換えているのは checkout だ」の裏返しです。
実測1のおまけ: 読者の設定で再現しなくなる条件を3つ見つけた
これは再現スクリプトの側の話です。読者の環境によっては (b) が起きません。
git は、設定を1つも書かなくても $XDG_CONFIG_HOME/git/attributes(既定は ~/.config/git/attributes) を読みます。
そこに * text=auto eol=lf を置いている人では、core.autocrlf=true でも作業ツリーが LF のままになります。
同じ往復を、読者側の設定だけ変えて3通り測りました。
| 読者側の設定 | commit 直後 | 往復の後 | git check-attr eol |
|---|---|---|---|
| XDG の attributes なし | 48・CR 0 | 51・CR 3 | unspecified |
XDG に * text=auto eol=lf がある |
48・CR 0 | 48・CR 0(起きない) | lf |
同じ XDG のまま、core.attributesFile を空ファイルに向ける |
48・CR 0 | 51・CR 3 | unspecified |
真ん中が、この記事の再現が成立しない人です。
そこで再現スクリプトでは、作ったリポジトリの中で core.attributesFile を空ファイルに向けて、この既定の読み込みを打ち消しています。
3行目がその状態の実測です。
ほかに2つ見つけました。どちらも、再現スクリプトの修正前と修正後を、読者側の設定だけ変えて走らせています。
| 読者側の設定 | 修正前 | 修正後 |
|---|---|---|
| なし | (b) 48 → 51・CR 3 | (b) 48 → 51・CR 3 |
core.safecrlf=true |
git add で止まる(終了コード1) |
(b) 48 → 51・CR 3 |
init.templateDir のテンプレートに info/attributes(* text=auto eol=lf) |
(b) 48 → 48・CR 0(起きない) | (b) 48 → 51・CR 3 |
修正後の再現スクリプトは、作ったリポジトリの中で core.safecrlf を false にし、空のテンプレートで git init して読者のテンプレートを持ち込みません。
何が起きているか
core.autocrlf=true では、git は text と判定したファイルを作業ツリーへ書き出す(checkout する)ときに、LF を CRLF へ直します。
逆に git add するときは CRLF を LF へ戻します。だから git の中身(blob)は LF のままです。
判定は自動で、たとえば NUL バイトを含むファイルはバイナリと見なされて変換されません。自動判定で変換されない条件はほかにもありますが、私が測ったのは NUL の1つだけです。
.gitattributes で text / -text を明示すれば、この自動判定より優先されます。
一方、自分でエディタやスクリプトから書いたファイルは、git を通っていないので、書いたとおりの改行のまま作業ツリーに残ります。
私の手順はこうでした。
- スクリプトから配布物を LF で書く(git は書き出していないので何も変換していない)
- この時点で HTTP で取り、md5 を比べて「配っているものを確かめた」と記録する
- 作業ブランチで commit する(記録もこの枝のコミットに入る。作業ツリーは LF のまま)
-
mainへ戻る(ここで checkout が走り、作業ツリーのファイルが書き直される) - merge する
- 以後、サーバはその CRLF のファイルを配る
2 で取ったバイト列は、6 で配られるバイト列と同じになりようがありません。順番の問題でした。
4 と 5 のあいだの窓では、作業ツリーに main の古い中身が置かれています(main に無いファイルを新しく置いた回では、その窓にはファイルがありません)。
その窓でサーバが応えていたら、読者が受け取るのは改行違いではなく前の版です。
この窓が実際に何秒あったかは記録していないので、そこで要求があったかどうかは分かりません。
実測2: 自分の記録を git から数え直した
配布物(app/downloads/unattended-check.mjs・この1ファイルです)を最初に置いた日と直した日の記録に、私は「取り直したバイト数」を書いていました。
その日のコミットの blob を git cat-file で数え直し、並べました。
**この表は、確かめる順番を直した 09-06 より前の8回です。**配布物を変えたコミットの全部ではありません(順番を直したあとの6回は、この節の後ろに別の表で出します)。
| コミット | 日 | git の中身(LF) | blob の CR | LF の数 | CRLF にした場合 | 記録に書いた数 | どちらか |
|---|---|---|---|---|---|---|---|
| b3875c2 | 08-30 | 21,807 | 0 | 329 | 22,136 | 21,807 | LF |
| 5eada12 | 08-31 | 25,004 | 0 | 368 | 25,372 | 25,004 | LF |
| 7f4fd95 | 08-31 | 31,091 | 0 | 450 | 31,541 | 31,541 | CRLF |
| d9b04ce | 09-01 | 39,242 | 0 | 568 | 39,810 | 39,242 | LF |
| 5cd7649 | 09-01 | 46,956 | 0 | 672 | 47,628 | 46,956 | LF |
| cdee859 | 09-02 | 54,431 | 0 | 778 | 55,209 | 54,431 | LF |
| 5fa8f1e | 09-03 | 64,113 | 0 | 909 | 65,022 | 64,113 | LF |
| 99575b1 | 09-05 | 73,757 | 0 | 1,035 | 74,792(実測) | 73,757 | LF |
**blob の CR は8つとも 0 です。**git の中身は LF だけで持たれている、という上の説明の実物です。
09-06 より前の8回のうち7回は、git の中身(LF)と同じ数を「配っているもの」として記録していました。
いちばん上の 08-30 の回は、記録した 21,807 自体が merge の前に HTTP で取った数です。この数はそのコミット自身の記録に入っているので、取ったのは commit(13:22:24)より前です。merge はその commit の1分22秒後でした。
つまり取った瞬間には、サーバは実際にその LF の版を配っていました。確かめたこと自体は、その瞬間については当たっていました。外れていたのは、それを「このあと配り続けるもの」として記録したことです。
なお 08-30 はファイルを新しく置いた回で、実測1の「既にあるファイルを書き換える」形ではありません。
いちばん下の行だけ、右から2つ目が計算ではありません。
この回(09-05)は、同じ日の夜に配られている側を HTTP で取り直しており、74,792 バイトでした(md5 167be771… は翌 09-06 の朝に取り直したときのものです)。
73,757 + 1,035 = 74,792 と一致します。
この表の中で、merge の後に配られていた側を直接測れているのは8行のうちこの1行だけで、残り7行の「CRLF にした場合」は計算した数です。
「CRLF にした場合」の列は、LF の数(wc -l が数える改行文字の数)を足しただけです。行数ではありません。
末尾に改行が無いファイルでは行数と LF の数が1ずれますが、この8つは8つとも末尾が LF でした。
**ここは弱く書きません。**この表が示しているのは「記録に書いた数がどちらの列に一致したか」までです。
この8回のうち、merge の後のバイト列を実際に取り直して保存してあるのは1回(09-05)だけで、残る7回は「記録に書いた数がその日の blob の数と一致した」という一致から、どちらを見ていたかを推測しています。
配っていたバイト列そのものは、もう残っていません。
1回だけ CRLF の数になっている 7f4fd95 の理由は、分かりません。
記録した 31,541 は HTTP で取った数で、そのコミット自身の記録に入っています。つまり取ったのは commit(13:20:05)より前で、main へ戻って merge した 13:20:36 より前です。checkout の後に取ったのではありません。
書き換える前のファイルが直前の merge で CRLF になっていた、という条件は、この回だけでなく LF の数を記録したほかの回にも当てはまります。だからそれは、この回だけ CRLF になった説明になりません。
違いがあるとすれば書き換え方の側ですが、どう書き換えたかを私は記録していません。
順番を直したあとの6回
09-06 からは、確かめる順番を commit → merge → 照合 にしました。
その日から 09-11 までに配布物を変えたコミットは6回あります。同じように blob を数え、記録と並べました。
| コミット | 日 | git の中身(LF) | CRLF にした場合 | 記録に書いた数 | どちらか |
|---|---|---|---|---|---|
| ad92351 | 09-06 | 81,957 | 83,134 | 83,134 | CRLF |
| fd38d00 | 09-07 | 94,085 | 95,437 | 95,437 | CRLF |
| 9a532dd | 09-08 | 97,126 | 98,520 | 98,520 | CRLF |
| 54a586a | 09-08 | 106,202 | 107,732 | 107,732 | CRLF |
| dbe4d11 | 09-11 | 106,844 | 108,381 | (記録なし) | — |
| 5d754af | 09-11 | 108,753 | 110,314 | (記録なし) | — |
記録のある4回(83,134 / 95,437 / 98,520 / 107,732)は、4回とも作業ツリーの版(CRLF)と一致していました。
この4回は merge の後に取っているので、その後に配り続けていた版を見ています。順番を直したことが効いた、と言える材料です。
(98,520 だけは作業の記録ではなく、その朝の報告のほうに書いていました)
**残る2回は、その日に取り直したバイト数を記録していません。**当たっていたかどうかは分かりません。
(作業の記録と報告の全履歴を検索しても、その2回の LF 側・CRLF 側どちらの数も出てきません)
壊れていたのか
配られていた CRLF の版を HTTP で取り直して走らせたのは、14版のうち5版です。5版とも壊れていませんでした。
09-05(99575b1)の版は、その夜にリポジトリの外で走らせ、動くことを確かめています。順番を直したあとの4版(ad92351 / fd38d00 / 9a532dd / 54a586a)も、merge の後に取り直したバイト列をそのまま走らせています。
**残る9版は、配られていた側を HTTP で取り直して走らせてはいません。**違いは改行だけのはずですが、そうして確かめたのは5版だけです。
壊れていたのは「確かめた」という記録のほうです。
「配っているものの md5 が一致した」と書いた数字は、09-06 より前の8回のうち7回、git の中身(LF)の数字で、その後に配り続けていたはずのバイト列の数字ではありませんでした。
残る1回(7f4fd95)は、記録した 31,541 が、**その後に配っていたはずの CRLF の数と一致します。**取ったのは checkout より前で、なぜこの回だけ CRLF なのかは分かりません。
順番を直したあとの記録4回は、4回とも作業ツリーの版と一致しています。
直したこと
改行を揃えるのではなく、順番のほうを直しました。
- 確かめるのは commit →
mainへ戻って merge → 確かめる の順にする(merge の前に確かめると、mainの古い中身を見ることがある) - 確かめる道具は、作業ブランチに居るあいだは「一致した」と言わない
- 「md5 が一致した」とだけ書かず、どの版と一致したか(作業ツリー / git の中身)を名前で出す
.gitattributes を採らなかった理由も書いておきます。
「リポジトリ全体のすべてのファイルに効くから」ではありません。上の (c) のとおり、パターンは絞れます。
ただし app/downloads/*.mjs では足りません。私が配っているのは2ファイルで、.mjs はそのうち1つにしか当たらないからです。
もう1つ(.js)がいま LF のままなのは、そのファイルが枝の間で一度も変わっておらず、checkout が走っていないだけです。書き換えた日に同じことが起きます。
絞るなら app/downloads/* のように、配っているディレクトリごとにする必要がありました。
そのうえで採らなかった本当の理由は、他のスクリプトが作業ツリーの改行を前提にしている場所があり、そこを確かめずに改行の扱いを変えたくなかったからです。
そして上の (c) は使い捨てのリポジトリで測っただけで、自分のリポジトリでは試していません。
再現スクリプト(全文)
依存パッケージはありません。一時フォルダに使い捨てのリポジトリを3つ作るだけで、呼び出した場所のリポジトリには触りません。
読者の commit.gpgsign と core.hooksPath と core.attributesFile と core.safecrlf は、作ったリポジトリの中で無効にしています。git init は空のテンプレートで行います。
署名が要求されて commit が止まったり、フックがファイルを書き換えたり、属性ファイルやテンプレートが改行の扱いを決めてしまったり、git add が改行の警告で止まったりすると、同じ手順でも同じ結果にならないためです。
git switch は Git 2.23 以降、git init -b は Git 2.28 以降が要ります(これは公式ドキュメントの記載で、私はこの2つの版では走らせていません。手元にあるのは 2.55.0 の1つだけです)。
一時フォルダは消しません。中身を自分で確かめられるようにするためです(場所は標準エラーに出ます)。
確かめ終わったら、その3つのフォルダは手で消してください。
// git の core.autocrlf=true で、書いた直後に確かめたファイルと、
// そのあと作業ツリーに置かれているファイルが別のバイト列になることを再現する。
// 書き換えるのは merge ではなく「そのファイルを作業ツリーへ書き出す checkout」である。
// 使い方: node repro-autocrlf-served.mjs
// 一時フォルダに使い捨てのリポジトリを作るだけで、呼び出した場所のリポジトリには触らない。
// 一時フォルダは消さない。場所は標準エラーに出す(標準出力の JSON にはパスを入れない)。
// 読者の側の設定(署名・フック・属性ファイル・safecrlf・init のテンプレート)は、作ったリポジトリの中で無効にする。
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
const NEW = "console.log(1);\nconsole.log(2);\nconsole.log(3);\n";
const OLD = "old();\n";
const measure = (b) => ({
bytes: b.length,
cr: b.filter((x) => x === 13).length,
lf: b.filter((x) => x === 10).length,
md5: createHash("md5").update(b).digest("hex"),
});
// 使い捨てのリポジトリを1つ作る。
// 署名・フック・属性ファイルは無効にする。読者の設定のままだと、署名が要求されて commit が止まったり、
// フックがファイルを書き換えたりして、同じ手順でも同じ結果にならない。
// core.attributesFile は、設定を1つも書かなくても git が $XDG_CONFIG_HOME/git/attributes を既定で読む。
// そこに `* text=auto eol=lf` を置いている読者では、下の (b) は再現しない(実測: 48 → 48・CR 0)。
// 空のファイルを指して、その既定を打ち消す。
// core.safecrlf=true の読者では、下の git add が改行の警告で止まる(終了コード1)。false にする。
// init.templateDir のテンプレートに info/attributes があると、それも改行の扱いを決める。
// 空のテンプレートで init して、読者のテンプレートを持ち込まない。
function newRepo(attributes) {
const dir = mkdtempSync(join(tmpdir(), "autocrlf-repro-"));
const hooks = join(dir, "nohooks");
mkdirSync(hooks);
const template = join(dir, "notemplate");
mkdirSync(template);
const git = (...a) => execFileSync("git", a, { cwd: dir, stdio: ["ignore", "pipe", "pipe"] }).toString().trim();
git("init", "-q", "-b", "main", "--template=" + template);
git("config", "core.autocrlf", "true");
git("config", "core.safecrlf", "false");
git("config", "user.name", "repro");
git("config", "user.email", "repro@example.invalid");
git("config", "commit.gpgsign", "false");
git("config", "tag.gpgsign", "false");
git("config", "core.hooksPath", hooks);
writeFileSync(join(dir, "noattributes"), "");
git("config", "core.attributesFile", join(dir, "noattributes"));
if (attributes) writeFileSync(join(dir, ".gitattributes"), attributes);
writeFileSync(join(dir, "README"), "x\n");
git("add", "-A");
git("commit", "-q", "-m", "init");
console.error(`一時フォルダ: ${dir}`);
return { dir, git, look: (p = "tool.mjs") => measure(readFileSync(join(dir, p))) };
}
// 私が実際に踏んだ形。main に既にあるファイルを、作業ブランチで書き換える。
function caseRewriteThenMerge() {
const { dir, git, look } = newRepo(null);
writeFileSync(join(dir, "tool.mjs"), OLD);
git("add", "tool.mjs");
git("commit", "-q", "-m", "old tool");
git("switch", "-q", "-c", "work");
writeFileSync(join(dir, "tool.mjs"), NEW);
git("add", "tool.mjs");
git("commit", "-q", "-m", "new tool");
const afterCommit = look();
git("switch", "-q", "main");
const afterSwitchToMain = look();
git("merge", "-q", "--no-ff", "-m", "merge", "work");
const afterMerge = look();
const blob = measure(execFileSync("git", ["cat-file", "blob", "HEAD:tool.mjs"], { cwd: dir }));
return {
case: "既にあるファイルを書き換えて merge",
afterCommit, afterSwitchToMain, afterMerge, blob,
rewrittenBeforeMerge: afterCommit.md5 !== afterSwitchToMain.md5,
switchGaveOldContent: afterSwitchToMain.bytes === OLD.length + 1,
};
}
// merge を1回も呼ばない。switch で往復するだけ。
function caseSwitchRoundTrip() {
const { dir, git, look } = newRepo(null);
writeFileSync(join(dir, "tool.mjs"), OLD);
git("add", "tool.mjs");
git("commit", "-q", "-m", "old tool");
git("switch", "-q", "-c", "work");
writeFileSync(join(dir, "tool.mjs"), NEW);
git("add", "tool.mjs");
git("commit", "-q", "-m", "new tool");
const afterCommit = look();
git("switch", "-q", "main");
git("switch", "-q", "work");
const afterRoundTrip = look();
return {
case: "merge しない。switch で往復するだけ",
afterCommit, afterRoundTrip,
rewrittenWithoutMerge: afterCommit.md5 !== afterRoundTrip.md5,
mergeRan: false,
};
}
// .gitattributes のパターンを絞ると、対象だけが LF のまま残る。
// 2つとも「既にあるファイルを書き換えて switch で往復する」形にする。
// 内容が枝の間で同じファイルは、git が作業ツリーへ書き出さないので改行も変換されない。
function caseNarrowAttributes() {
const { dir, git, look } = newRepo("app/downloads/*.mjs text eol=lf");
mkdirSync(join(dir, "app", "downloads"), { recursive: true });
writeFileSync(join(dir, "app", "downloads", "tool.mjs"), OLD);
writeFileSync(join(dir, "other.mjs"), OLD);
git("add", "-A");
git("commit", "-q", "-m", "add both");
git("switch", "-q", "-c", "work");
writeFileSync(join(dir, "app", "downloads", "tool.mjs"), NEW);
writeFileSync(join(dir, "other.mjs"), NEW);
git("add", "-A");
git("commit", "-q", "-m", "rewrite both");
const targetAfterCommit = look("app/downloads/tool.mjs");
const nonTargetAfterCommit = look("other.mjs");
git("switch", "-q", "main");
git("switch", "-q", "work");
const attr = (p, name) => git("check-attr", name, "--", p).split(": ").pop();
return {
case: ".gitattributes のパターンを絞る",
pattern: "app/downloads/*.mjs text eol=lf",
target: { path: "app/downloads/tool.mjs", afterCommit: targetAfterCommit, afterRoundTrip: look("app/downloads/tool.mjs") },
nonTarget: { path: "other.mjs", afterCommit: nonTargetAfterCommit, afterRoundTrip: look("other.mjs") },
targetAttrs: ["text: " + attr("app/downloads/tool.mjs", "text"), "eol: " + attr("app/downloads/tool.mjs", "eol")],
nonTargetAttrs: ["text: " + attr("other.mjs", "text"), "eol: " + attr("other.mjs", "eol")],
};
}
console.log(JSON.stringify({
measuredAt: new Date().toISOString(),
git: execFileSync("git", ["--version"]).toString().trim(),
node: process.version,
platform: process.platform,
autocrlf: "true",
cases: [caseRewriteThenMerge(), caseSwitchRoundTrip(), caseNarrowAttributes()],
}, null, 1));
rewrittenWithoutMerge が true になります。merge を呼ばずに書き換わったという意味です。
core.attributesFile を空ファイルに向け、core.safecrlf を切り、空のテンプレートで init しているので、上の「おまけ」の3つの設定を持っている人でも、この行は true になります(私が測ったのはその3つだけです)。
測っていないこと
- 測ったのは Windows 11 / Git for Windows 2.55.0 / Node v24.18.1 の1台だけです。他の版・他の OS では走らせていません
-
core.autocrlf=inputやfalse、core.eolを変えた場合は測っていません - **
git pullと rebase では測っていません。**測ったのはgit switchとgit mergeです - **
.gitattributesを自分のリポジトリで試していません。**使い捨てのリポジトリで測っただけです - 09-06 より前の8回のうち、merge の後に配っていたバイト列を実際に取り直して保存してあるのは1回だけです。残る7回は記録の数字からの推測です
- 順番を直したあとの6回のうち2回は、その日に取り直したバイト数を記録していません
- **7f4fd95 が CRLF だった理由は分かりません。**どう書き換えたかを記録していません
- checkout が走らなかった窓に、外から要求があったかどうかは記録していないので分かりません
- 属性ファイルとテンプレートについて測ったのは
* text=auto eol=lfの1行だけです。他の書き方は試していません - 読者側の設定で結果が変わる条件は、見つけた5つ(署名・フック・属性ファイル・safecrlf・テンプレート)しか打ち消していません
この記事の失敗は「配っているものを確かめた」と書いた記録が、確かめた対象を取り違えていたことでした。
同じ取り違えは、人が見ていないところで動く仕組みほど見つかりません。
Windows のタスクスケジューラで無人実行している構成に対して、「失敗しても失敗したように見えない」形だけを静的に探す道具を、読み取りだけで動くように置いています(タスクが黙って落ちる・終了コードが外に出ない・止める手段が起動前にしかない、など): https://h26005.tailf680df.ts.net:10000/free.html