# 指示書J — JSON ストアの共通化と安全化（`advice.ts` の3欠陥を潰す）

| 項目 | 内容 |
|---|---|
| 実行環境 | ★**Cursor 内の Claude が実装します**★（`.claude/commands/d-watch.md` の役割分担表を参照） |
| 実行状態 | **未実行** |
| 実行順 | ★**指示書G より先に実行すること**★（G の段階2-1 がこの部品を使う形に書き換わっています） |
| 取得する決定番号 | ★**D-075 から**★（D-074 まで使用済み。★着手直前に `grep -n "^### D-0" docs/decisions.md` で再確認すること★。**他の指示書と同時に走らせないこと**） |
| 着手前の基準 | `npm test` が **19ファイル / 424テスト 全パス**、`npx tsc --noEmit` エラー0、`npm run lint` 0（★2026-09-08 実測★） |

---

## ★この指示書は 指示書G の「やらないこと」を上書きします★

`docs/handoff/指示書G_D-Secログの備考入力と2状態化.md:499` に、こう書かれています。

> | **`lib/settings/advice.ts` のリファクタ** | 弱点は分かっているが、備考の実装とは別件。**触ると advice の挙動が変わる** |

★**この判断を撤回します。**★ 理由は次のとおりです。

- **`advice.ts` の弱点は、放置すると運用担当が書いた文章が消える性質のものです**（下記）
- ★**指示書G が `advice.ts` を「ひな型にする」と指示している**★（G `:284`）ため、
  **直さないと同じ欠陥が2つ目のストアにコピーされます**
- **`advice.ts` はパスをモジュール先頭で固定していてテストが書けません**。
  G が要求する「一時ディレクトリで実際のファイル読み書きを検証」（G `:314-327`）を満たすには、
  **パスを注入できる形の部品が必要**で、それは `advice.ts` 側の作りにも手を入れることになります

**この指示書で `advice.ts` を安全な部品に載せ替え、G はその部品を使うだけにします。**

---

## 何が問題なのか（★実測で確認済み★）

`lib/settings/advice.ts`（全85行）は ★**このリポジトリで唯一ファイルを書くコード**★ です
（`node:fs` の import はリポジトリ全体でこの1ファイルだけ。ビルド成果物でも確認済み）。
欠陥が3つあります。

### 欠陥1. 壊れた JSON を黙って捨て、次の保存で消す（★最も危険★）

```ts
// lib/settings/advice.ts:26-34
async function readOverrides(): Promise<AdviceOverrides> {
  try {
    const raw = await fs.readFile(SETTINGS_PATH, "utf-8");
    return JSON.parse(raw) as AdviceOverrides;
  } catch {
    return {};          // ★「ファイルが無い」と「壊れている」を区別していない★
  }
}
```

**`saveAdviceRule`（`:70-72`）は「読む → 1件マージ → 書く」の順で動きます。**

★**具体例**★: 10件のアドバイス文を登録済み。ファイルが壊れる（欠陥2）。すると—

1. 画面を開く → `JSON.parse` 失敗 → `{}` → **画面には既定値だけが出る**（10件が消えたように見える）
2. 運用担当が1件だけ書き直して保存 → **`{その1件}` だけのファイルが書かれ、残り9件が本当に消える**

★**`/data` は `.gitignore` されています**★（`.gitignore:46-47`「運用設定（実行時に生成される。
環境ごとに異なるためコミットしない）」）。**git にバックアップがありません。復旧手段がありません。**

### 欠陥2. 書き込みが非アトミック

```ts
// lib/settings/advice.ts:38
await fs.writeFile(SETTINGS_PATH, `${JSON.stringify(overrides, null, 2)}\n`, "utf-8");
```

`fs.writeFile` は**既存ファイルを切り詰めてから書きます**。
★**切り詰めた直後にプロセスが落ちると、ファイルは空か途中まで**になります★ → 欠陥1 の入口です。

★**具体例**★: dev サーバの再起動、デプロイでのプロセス入れ替え、VM の停止。
**保存ボタンを押した1秒間に当たるだけで全設定が消えます。**

### 欠陥3. テストが書けない

- `lib/settings/__tests__/` が**存在しません**（テスト0件）
- 原因は `advice.ts:22` — ★**`process.cwd()` 起点のパスをモジュール先頭の定数で固定している**★ため、
  **テストから保存先を差し替える手段がありません**
- リポジトリ全体に **`mkdtemp` / `tmpdir` を使ったテストの前例が1件もありません**（検索して0件）。
  **この指示書でその前例を作ります**

---

## 作業の順序（★崩さないこと★）

| 段階 | 内容 | コミット |
|---|---|---|
| 段階0 | ログイベント型の追加（`lib/logger/events.ts`）+ 設計文書の表を更新 | 段階0で1コミット |
| 段階1 | ★**テストを先に書く**★（`lib/settings/__tests__/json-store.test.ts`）→ 赤くなる | 段階1〜2をまとめて1コミット |
| 段階2 | `lib/settings/json-store.ts` を実装 → 緑にする | 同上 |
| 段階3 | `advice.ts` を新部品に載せ替え + `advice.ts` のテストを追加 | 段階3で1コミット |
| 段階4 | 記録と、指示書G への申し送り | 段階4で1コミット |
| ★**段階5**★ | ★**`origin/develop` へ push する**★ | コミットなし（push のみ） |

★**段階ごとに `npx tsc --noEmit` と `npm test` を実行し、通ってからコミットすること**★（D-050）。

---

## 段階0 — ログイベント型を追加する

★**`logEvent("warn", ...)` は、いま そのままでは呼べません。**★
`lib/logger/events.ts:217-226` の `LogEvent` は ★**閉じた allowlist の union で9種類しかなく**★、
「設定ストアの JSON が壊れていた」に相当するイベントが存在しません。

**追加するイベント型**（`lib/logger/events.ts`）:

- イベント名は ★**ドット区切りの名前空間**★（`docs/logging-design.md:105` の命名規約）。
  例: **`settings.store_corrupt`**
- フィールド: **ファイルパス / 退避先のパス / 理由**
- `LogEvent` の union に追加する
- ★**`docs/logging-design.md:108-114` のイベント一覧表にも追記すること**★（表と実装を乖離させない）

### ★フィールド名の地雷（これを踏むとテストどころか実行時に落ちます）★

`lib/logger/redact.ts:12-26` の禁止フィールド名に ★**`note` / `notes` / `comment` / `memo` / `detail`**★ が
含まれており、`redact.ts:161-173` の `enforceForbiddenFieldsPolicy` は
★**非本番環境では例外を throw します**★（「ログに禁止フィールドを含められません」）。
キー名は**大文字小文字を無視した完全一致**で、**ネストしたオブジェクトも再帰的に検査**されます。

したがって:

| やること | やってはいけないこと |
|---|---|
| `file_path` / `backup_path` / `reason` のようなキーを使う | ★**`detail` を使う**★（禁止キー） |
| パスと理由だけを載せる | ★**壊れたファイルの中身や、備考・アドバイス文の本文を載せる**★ |

---

## 段階1 — テストを先に書く（★これが目的の半分です★）

`lib/settings/__tests__/json-store.test.ts` を作ります。
`vitest.config.ts` は `environment: "node"` / `include: ["lib/**/*.test.ts", ...]` なので
★**追加設定なしで拾われ、`node:fs` と `node:os` がそのまま使えます**★。

★**一時ディレクトリを使うこと**★: `fs.mkdtemp(path.join(os.tmpdir(), "dwatch-"))` で作り、
**各テストで独立したディレクトリを使う**（テスト間で状態を持ち回らない）。
**このリポジトリ初の「実際にファイルを読み書きするテスト」になります。**

### 期待値表（★これを先に写すこと★）

#### `read()`

| 状況 | 期待 | 何を固定するか |
|---|---|---|
| ファイルが無い | ★**空の値**★（`empty()` の結果）。`.bak` は**作られない** | 「一度も保存していない」は正常系 |
| 正しい JSON がある | その内容 | 素の読み込み |
| ★**JSON が壊れている**★ | ★**`.bak` が作られ、空の値が返り、警告が1回出る**★ | ★**黙って捨てないこと**★ |
| ★**JSON が壊れていて、`.bak` の作成に失敗する**★ | ★**例外を投げる（空の値を返さない）**★ | ★**最重要。下の「なぜ投げるのか」を読むこと**★ |
| 中身が JSON だが配列や文字列 | ★**壊れている扱い**★（`.bak` + 空 + 警告） | オブジェクト以外を通すと後段が壊れる |
| キーに `__proto__` がある | ★**`Object.prototype` が汚染されない**★。`({}).polluted` が `undefined` のまま | プロトタイプ汚染 |
| キーに `constructor` / `prototype` がある | 同上 | 同上 |

★**なぜ `.bak` 作成に失敗したら例外を投げるのか**★

`.bak` を作れないまま「空の値」を返すと、**呼び出し側が次に `write()` した瞬間に
壊れたファイルが上書きされ、復旧できなくなります**（欠陥1 そのもの）。
**壊れたファイルを退避できないなら、処理を止めるのが正しい**。
画面には 500 が出ますが、★**データが消えるより 500 のほうがましです**★。
**この理由をコードのコメントに残すこと。**

#### `write()`

| 状況 | 期待 | 何を固定するか |
|---|---|---|
| ディレクトリが無い | **作られて書ける**（`mkdir` の `recursive`） | 初回保存 |
| 既存ファイルがある | **置き換わる** | ★`fs.rename` が既存ファイルを上書きできること★ |
| 書いた直後に `read()` | **同じ値が返る** | 往復 |
| 書き込み後 | ★**`.tmp` が残っていない**★ | 一時ファイルの後始末 |
| 書き込み中にプロセスが落ちた場合 | （★テストしにくいので**コメントで意図を残す**★） | アトミック性の根拠 |

#### 部分更新（★`advice` と `ds-device-notes` の両方で効く★）

| 状況 | 期待 |
|---|---|
| 2件保存 → 1件だけ更新 | ★**もう1件が消えない**★ |
| 2件保存 → 1件だけ削除 | ★**もう1件が消えない**★ |

---

## 段階2 — `lib/settings/json-store.ts` を実装する

### 2-1. 公開する形

★**パスを引数で受け取ること。**★ これが `advice.ts` の欠陥3（テスト不可能）の直接の原因です。

```ts
/**
 * DFA 独自ストア（JSON1ファイル）の読み書きを安全に行う共通部品。
 *
 * ★メインDBには書きません★（DBは TeamWorks 管理で読み取り専用。CLAUDE.md §7 禁止事項）。
 * 保存先は `data/settings/` 配下で、`/data` は .gitignore されています
 * （= ★git のバックアップがありません★。だから壊れたファイルは .bak に退避します）。
 *
 * ★ロックは入れません★ — 施設20件・実質1人の運用で、rename によるアトミック差し替えで
 * 「壊れたファイルが残る」ことは防げるため。同時保存は後勝ちになります。
 * 利用者が増えたらここにロックを入れること（呼び出し側は変えずに済みます）。
 */
export interface JsonStore<T extends object> {
  read(): Promise<T>;
  write(value: T): Promise<void>;
}

export function createJsonStore<T extends object>(options: {
  /** 保存先の絶対パス。★テストから差し替えられるように引数で受ける★ */
  filePath: string;
  /** 空の初期値を作る。★Object.create(null) を土台にすること★ */
  empty: () => T;
  /** 壊れた JSON を検知したときの通知。★lib/logger を import しないための注入★ */
  onWarn?: (info: { filePath: string; backupPath: string; reason: string }) => void;
}): JsonStore<T>;
```

★**`lib/logger` を `json-store.ts` から import しないこと。**★
`lib/logger/index.ts` は `@/lib/db` を経由して **fixtures 全体を引き込みます**。
ストア層がそれを抱えると、テストの依存グラフが不必要に重くなります。
**警告はコールバックで受け、`logEvent` を呼ぶのは呼び出し側（API ルート）にしてください。**
`lib/logger/index.ts` の `emitTo`（出力先を引数で受ける版）が**同じ流儀の前例**です。

### 2-2. `read()` の実装

1. `fs.readFile` → **`ENOENT` なら `empty()` を返す**（`.bak` は作らない）
2. `JSON.parse` を試す。**失敗したら、またはオブジェクトでなかったら**破損扱い
3. 破損時: ★**`.bak` に退避 → `onWarn` を呼ぶ → `empty()` を返す**★
   - `.bak` の名前は ★**タイムスタンプ付き**★（例: `advice.json.2026-09-07T12-34-56.bak`）。
     **固定名にすると2回目の破損で1回目の証拠が消えます**
   - ★**退避に失敗したら例外を投げる**★（段階1 の「なぜ投げるのか」）
4. ★**プロトタイプ汚染対策**★: パース結果をそのまま返さず、
   **`Object.create(null)` を土台にし、`Object.prototype.hasOwnProperty.call()` で
   確認しながら移し替える**

★**`advice.ts` には汚染の問題がありませんでした**★（キーが閉じた union だったため）。
**`ds-device-notes` は `serial_number` が任意文字列なので、`__proto__` がキーとして飛んできます**
（指示書G `:102-109`）。**共通部品の側で防ぐのが正しい場所です。**

### 2-3. `write()` の実装

1. `fs.mkdir(path.dirname(filePath), { recursive: true })`
2. ★**`<filePath>.tmp` に書く**★
3. ★**`fs.rename(tmp, filePath)`**★ でアトミックに差し替える
4. 失敗したら `.tmp` を片付ける（★**残骸を残さない**★）

★**`fs.rename` が既存ファイルを上書きできることを、テストで必ず確認すること。**★
**開発機は Windows、本番は Linux** です。挙動が違わないことをテストで固定してください。

---

## 段階3 — `advice.ts` を載せ替える

### 3-1. 外から見える形は変えない

★**`loadAdviceRules` / `saveAdviceRule` / `resetAdviceRule` のシグネチャを変えないこと。**★
呼び出し元は2箇所だけです（実測）。**どちらも変更不要にしてください。**

- `app/api/settings/advice/route.ts:13` — `loadAdviceRules` / `saveAdviceRule` / `resetAdviceRule`
- `app/api/clean-logs/[id]/advice/route.ts:15` — `loadAdviceRules`（読み取りのみ）

### 3-2. 中身の置き換え

- `readOverrides` / `writeOverrides`（どちらも非 export）を ★**`createJsonStore` の呼び出しに置き換える**★
- **保存先の既定は今と同じ** `path.join(process.cwd(), "data", "settings", "advice.json")`
- ★**テストからパスを差し替えられる形にすること**★
  （既定値を持つ引数にする、ストアを作る関数を export する、等。**モジュール先頭で固定しない**）
- `onWarn` には `logEvent("warn", ...)` を渡す。**段階0 で追加したイベント型を使う**

### 3-3. あわせて直す小さな非対称

`resetAdviceRule`（`:78-85`）に ★**`id` の存在チェックがありません**★。
`saveAdviceRule`（`:66-68`）にはあります。**同じ形に揃えてください。**

★**現在は API から到達できません**★（`app/api/settings/advice/route.ts:66-69` が
ルート側で `DEFAULT_ADVICE_RULES` と突き合わせて 422 で弾いているため）。
**それでも揃えるのは、ライブラリを直接呼ぶコードが将来増えたときのためです。**

### 3-4. `advice.ts` のテストを追加する

`lib/settings/__tests__/advice.test.ts` を作ります。**一時ディレクトリを使うこと。**

| # | 何を固定するか |
|---|---|
| 1 | 未編集なら `loadAdviceRules()` が **`lib/config/advice-rules.ts` の既定値をそのまま返す** |
| 2 | 1件保存 → `loadAdviceRules()` にその1件だけが反映され、**他は既定値のまま** |
| 3 | ★**2件保存 → 1件だけ上書き → もう1件が消えない**★（欠陥1 の再発防止） |
| 4 | `resetAdviceRule` で**既定値に戻り、他の編集が消えない** |
| 5 | ★**壊れた JSON があるとき、`.bak` が作られ、既定値で動き、警告が出る**★ |
| 6 | ★**壊れた JSON を読んだ直後に保存しても、`.bak` の中身が失われていない**★ |
| 7 | 未知の `id` を `saveAdviceRule` / `resetAdviceRule` に渡すと**どちらも throw する** |

★**5と6が、この指示書で一番守りたい挙動です。**★

---

## 段階4 — 記録と申し送り

### `docs/decisions.md`（★着手直前に最終番号を確認★）

- ★**指示書G `:499` の「`advice.ts` のリファクタはやらない」を上書きしたこと**★とその理由
- `.bak` を**タイムスタンプ付き**にした理由（固定名だと2回目の破損で証拠が消える）
- ★**`.bak` の作成に失敗したら例外を投げる**という判断とその理由★
- **ロックを入れない**理由（施設20件・実質1人。rename で「壊れたファイルが残る」ことは防げる）
- 段階0 で追加したログイベント名と、`note` / `comment` / `memo` / `detail` が
  ★**`redact.ts` の禁止キーでログに入れられない**★こと
- 実測値（`npm test` のファイル数・テスト数 / `npx tsc --noEmit` / `npm run lint`）

### `docs/handoff/指示書G_D-Secログの備考入力と2状態化.md` を更新すること

★**G の段階2-1 は、この部品を使う形に書き換えてください。**★

- 「`advice.ts` をひな型にする」→ ★**「`lib/settings/json-store.ts` を使う」**★
- **アトミック書き込み・`.bak` 退避・プロトタイプ汚染対策は共通部品側で済んでいる**ので、
  `ds-device-notes.ts` は ★**`createJsonStore` を呼んで薄く包むだけ**★ になる
- G の「やらないこと」から ★**`advice.ts` のリファクタの行を削除**★（この指示書で実施済み）
- G `:509` の**基準テスト数 `267` は古い**。★**実測値に更新すること**★

### その他

| 文書 | 内容 |
|---|---|
| `docs/decisions.md` の **D-065** | ★**「保存先の具体（SQLite か JSON か）は未決」という記述が残っています**★。**JSON で確定したことを追記**（決定は指示書G と `docs/handoff.md` にしかない状態） |
| `docs/logging-design.md:108-114` | 段階0 で追加したイベントを**一覧表に追記**（済んでいなければここで） |
| `docs/handoff.md` | **末尾に追記を1つ追加**。冒頭の「最新の追記」ポインタも更新 |

---

## 段階5 — ★`origin/develop` へ push する★

**段階0〜4 がすべて緑でコミット済みになってから行ってください。**

```bash
git push origin develop
```

### ★知っておくべきこと: `origin/develop` はまだ存在しません★

**2026-09-08 時点で、このブランチは一度も push されていません**
（`git log origin/develop` が `unknown revision`。`git push --dry-run` が
`* [new branch] develop -> develop` を返す）。
つまり ★**この push が develop ブランチをリモートに作る最初の push**★ になり、
**それまでの全コミットがまとめて送られます。**

- **`--force` は絶対に使わないこと。** 新規ブランチの作成なので不要です
- ★**`main` へは push しないこと**★。`CLAUDE.md` §6-5 のとおり
  **main へのマージは PR 経由で、もう1人のレビューを通してから**行います
- **push 権限**: 2026-09-03 に 403（Read-only）で失敗した経緯がありますが、
  **2026-09-08 の `--dry-run` は成功しました**（権限は付与済みと見られます）。
  ★**それでも 403 が出たら、無理に回避せずユーザーへ報告してください**★
  （認証情報を画面に出すコマンドを実行しないこと。`git credential fill` は**禁止**）

### push 後に確認すること

```bash
git log --oneline -1 origin/develop
git status -sb
```

- `origin/develop` が**ローカルの HEAD と同じコミット**を指していること
- `git status -sb` の1行目が `## develop...origin/develop`（ahead/behind なし）であること
- ★**この2つを段階4の記録に実測値として書き足すこと**★

---

## ★踏まないでほしい地雷★

| # | 内容 |
|---|---|
| 1 | ★**`json-store.ts` から `lib/logger` を import しないこと**★。`@/lib/db` 経由で fixtures 全体を引き込みます。警告はコールバックで受ける |
| 2 | ★**ログのフィールドに `note` / `notes` / `comment` / `memo` / `detail` を使わないこと**★。`redact.ts` の禁止キーで、**非本番では例外が飛びます** |
| 3 | ★**壊れたファイルの中身をログに出さないこと**★。運用担当が書いた文章が入っています |
| 4 | ★**`.bak` の退避に失敗したときに「空の値」を返さないこと**★。次の保存で壊れたファイルが消えます |
| 5 | ★**`advice.ts` の3つの export のシグネチャを変えないこと**★。呼び出し元2箇所を変更不要にする |
| 6 | ★**テストを一時ディレクトリで行うこと**★。`data/settings/advice.json`（実在しません）を触らない |
| 7 | **`cwd` のドライブレターは必ず大文字 `C:\`**。小文字だと Vitest が全滅します |
| 8 | **検証が通ったら即コミットする**（D-050） |
| 9 | **決定番号は追記直前に最終番号を確認する**。★2026-09-07 に `D-068` の二重採番が実際に起きました★ |

---

## 完了条件

1. `npx tsc --noEmit` エラー0 / `npm run lint` 0
2. `npm test` 全パス。★**テスト数が 389 から減っていないこと**★
3. `lib/settings/__tests__/json-store.test.ts` と `lib/settings/__tests__/advice.test.ts` が存在し、
   ★**一時ディレクトリで実際にファイルを読み書きしていること**★
4. ★**壊れた JSON → `.bak` に退避 → 既定値で動く → 警告が出る**★ がテストで固定されていること
5. ★**`.bak` 作成に失敗したら例外**★ がテストで固定されていること
6. ★**`__proto__` をキーにしても `Object.prototype` が汚染されない**★ がテストで固定されていること
7. `advice.ts` の3つの export のシグネチャが変わっておらず、**呼び出し元2箇所が無変更**であること
8. 指示書G の段階2-1 と「やらないこと」と基準テスト数が更新されていること
9. `docs/decisions.md` / `docs/logging-design.md` / `docs/handoff.md` の更新
10. ★**`origin/develop` へ push 済みで、`git status -sb` に ahead/behind が無いこと**★
    （★`main` へは push しない★。main へのマージは PR 経由。`CLAUDE.md` §6-5）
