# fix: 末尾スラッシュ付き cwd のセルにライブリロードが届かない (#1002)

## 症状

`.mulmoterminal.json` を書いてもセルの色・名前バッジが変わらない。リロードすると反映される。
末尾スラッシュ付きの cwd で開いたセルでだけ起きる。

## 真因 — 検証はするのに正規化しない

issue は publish 側（`path.dirname` で必ずスラッシュが落ちる）と subscribe 側（cwd 完全一致の
Map）の表記ゆれまで特定していた。**その手前に発生源がある。**

`server/config/workspace.ts` の `existingWorkspace`:

```ts
if (!cwd || !path.isAbsolute(cwd)) return null;
return statSync(cwd).isDirectory() ? cwd : null;   // 検証は通すが、入力をそのまま返す
```

`/a/b/` は `isAbsolute` も `isDirectory` も通るので、**そのまま実効 cwd になる**。この戻り値は
下流で「そのディレクトリの ID」として使われる — PTY の cwd、セルへ返す cwd、dir-config の購読
キー、プリセットに記録するパス。つまり 1 つのディレクトリが 2 つの名前を持つ。

実測:

```
入力           "/Users/…/proj/"
isAbsolute     true
statSync isDir true
publish 側     "/Users/…/proj"    ← path.dirname
一致           false
```

`sanitizePresets` も空白を trim するだけでパスに触れないため、シェルの補完が付けた末尾
スラッシュが `cwdPresets` に永久に残る。

## 修正 — 入口 1 か所

`resolveWorkspace` は 7 か所（`session-routes.ts` ×5、`ws-routes.ts` ×2）から呼ばれる唯一の
関門で、WS 接続もここを通る。ここを正規形にすると連鎖で片付く:

1. セッションの cwd が正規形になる
2. サーバがそれをセルへ返す → `serverCwd` → `Terminal.vue` の `dirConfigCwd` = 購読キーが
   publish と一致する
3. `recordPreset` は**サーバ確定の cwd で呼ばれる**（`TerminalCell.vue` の `onServerCwd`）ので、
   既存の末尾スラッシュ付きプリセットは次回起動時に書き直される

issue の Suggested fix は「publish / subscribe / fetch の全部を正規化」だったが、subscribe 側は
2 の連鎖で直り、fetch 側はもともと壊れていない。

### 共有ヘルパーは `path-within.ts` へ

`canonicalDir()` を `server/infra/path-within.ts` に置いた。あのファイルは "One rule, one place"
として #802（**未 resolve のパスを resolve 済みと比較していた**＝同じ族のバグ）から生まれた場所で、
既に同じ `platformPath(platform).resolve` を内部に持っている。

**大文字小文字は畳まない。** 同ファイル内の private `normalize` は Windows で lowercase するが、
あれは*比較*用。こちらは PTY に渡り、UI に出て、config.json に書かれる*保存*値なので、畳むと
3 つとも壊れる。

**絶対パス限定。** `path.resolve` は相対文字列をサーバ自身の cwd に接ぎ木してしまう。呼び出し側は
どちらも先に `isAbsolute` を見ている。

## プリセットの重複 — 自分の修正が生む回帰

`recordPreset` はパス文字列の**完全一致**でチップを探す。上の修正だけ入れると、保存済みの
`/a/b/` に対してサーバが `/a/b` を返すので `existing` が見つからず、**同じディレクトリのチップが
2 つ並ぶ**。これは推測ではなく `useAppConfig.ts:122` のコードそのもの。

そこで `sanitizePresets` でもパスを正規化し、正規化後に重複を潰す。**先勝ち**（リストは MRU 順
なので、残るのが新しい方＝ユーザーが付け替えたラベルも残る）。`sanitizePresets` は読み取り
（`loadPresets`）と書き込み（`mergeConfigUpdate`）の両方を通るので、既存の config.json も
次の書き込みで自己修復する。

## 掃いたが直さなかったもの

`server/files/pathContainment.ts` の `resolveBase` が**同じ形**（検証だけして verbatim 返し、
コメントにも "mirrors index.ts resolveWorkspace"）。ただし消費側が全員 `path.resolve` を
掛け直している（`containedPath`、`authorizedServingBase`）ため、現状は無害。#1002 の原因では
ないので触っていない。**見落としではなく判断**として記録しておく。

## テスト

対照実験で修正を外すと 5 本が赤、戻すと緑（`workspace.spec` 1 / `cwd-presets.spec` 3 /
`dir-config.spec` 1）。

いちばん効くのは `dir-config.spec` の 1 本で、**publish 側と subscribe 側が同じ文字列を作る**
という不変条件を直接押さえている:

```ts
const announced = dirConfigWriteTarget("Write", { file_path: … }, dir + path.sep);
expect(announced).toBe(resolveWorkspace(dir + path.sep));
```

この 2 つは別のコード（`path.dirname` と workspace ガード）が作り、クライアントは完全一致で
突き合わせる。綴りを揃え続けるものが他に無い。

なお既存の `sanitizePresets` テストが `path: "/a"` という POSIX リテラルを期待していた。
**Windows では `/a` はドライブ相対で `C:\a` に解決される**ため、正規化を入れた時点で非可搬に
なる。期待値を `path.resolve` 経由に直した（CI は ubuntu/macOS のみだが、Windows は対応対象）。

## レビューで直したところ: stat と resolve の順序 (#1016, Codex)

最初の実装は **stat してから canonicalize** していた。Codex の指摘どおり、これは
**検証していないパスを返し得る**回帰だった。

`stat` はカーネルが symlink を辿り、`path.resolve` は純粋に字句的なので、**symlink の直後に
`..` が来た瞬間に両者は別の場所を指す**:

```
<dir>/link -> <dir>/sub/target
<dir>/link/../sibling   カーネル: link -> sub/target, .. -> sub  =>  <dir>/sub/sibling（存在する）
                        字句    :                                    <dir>/sibling    （存在しない）
```

stat→resolve だと入力は受理され、`<dir>/sibling` が返る。誰も検証しておらず、たいてい存在しない。

**canonicalize してから stat する**順に変えた。返す値が検証した値そのものになる。

`realpath` は使わない。announce 側は `path.dirname`（字句）で綴るので、片側だけ物理解決すると
#1002 の不一致が別の形で戻る。

### テストのフィクスチャが 2 回間違っていた

1 回目は symlink の親と対象の親が同じで、字句解決と一致してしまい**両方の順序で緑**だった。
2 回目は `path.join(link, "..", "sibling")` と書いたが、**`path.join` は構築時に `..` を畳む**ので
symlink を前に置いたままカーネルへ渡らず、フィクスチャ自体が ENOENT で落ちた。文字列連結で
組み直して初めて対照実験が成立した（修正版=緑、stat→resolve=赤）。

そのためテスト内にフィクスチャ自身のガードを入れてある — 乖離が本当に起きていることを
先に assert しないと、本命の assert が空振りしても気づけない。
