Newer
Older
client_dwatch / docs / handoff / 指示書J_JSONストアの共通化と安全化.md

指示書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 を黙って捨て、次の保存で消す(★最も危険★)

// 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. 書き込みが非アトミック

// 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(テスト不可能)の直接の原因です。

/**
 * 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 がすべて緑でコミット済みになってから行ってください。

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 後に確認すること

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)